role()->create(); } function teamFamily(): PlanFamily { return PlanFamily::query()->where('key', 'team')->sole(); } it('is closed to operators without the capability', function () { $viewer = Operator::factory()->role('Support')->create(); // The route middleware lets any operator into the console; the capability // is what decides who can see and change what the business sells. Livewire::actingAs($viewer, 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) ->assertForbidden(); // Including the pages themselves — prices, drafts and unreleased plans are // not something to leave readable to anyone who types the URL. $this->actingAs($viewer, 'operator')->get(route('admin.plans'))->assertForbidden(); $this->actingAs($viewer, 'operator')->get(route('admin.plans.versions', teamFamily()->uuid))->assertForbidden(); $this->actingAs(owner(), 'operator')->get(route('admin.plans'))->assertOk(); }); it('creates a plan line, and refuses a key that is not an identifier', function () { Livewire::actingAs(owner(), 'operator')->test(Plans::class) ->set('key', 'Agentur Paket') ->set('name', 'Agentur') ->set('tier', 5) ->call('save') ->assertHasErrors('key'); Livewire::actingAs(owner(), 'operator')->test(Plans::class) ->set('key', 'agency') ->set('name', 'Agentur') ->set('tier', 5) ->call('save') ->assertHasNoErrors(); $family = PlanFamily::query()->where('key', 'agency')->sole(); expect($family->name)->toBe('Agentur') ->and($family->tier)->toBe(5) // Nothing to sell yet: a family without a published version is not // in the shop, whatever its switch says. ->and(app(PlanCatalogue::class)->isSellable('agency'))->toBeFalse(); }); it('takes a plan out of the shop without touching anyone\'s contract', function () { $contract = Subscription::factory()->plan('team')->create(); Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleSales', teamFamily()->uuid); expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse() ->and(teamFamily()->sales_enabled)->toBeFalse() // The customer keeps everything they bought. ->and($contract->fresh()->price_cents)->toBe(17900) ->and($contract->fresh()->status)->toBe('active'); Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleSales', teamFamily()->uuid); expect(app(PlanCatalogue::class)->isSellable('team'))->toBeTrue(); }); it('drafts a version prefilled from the last one, priced for both terms', function () { $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); // Prefilled, so "same plan, new price" is one edit rather than nine. $page->assertSet('ramMb', 8192) ->assertSet('quotaGb', 500) ->assertSet('monthlyPrice', '179,00'); $page->set('monthlyPrice', '199,00')->set('yearlyPrice', '2388,00')->call('draft')->assertHasNoErrors(); $draft = teamFamily()->versions()->where('version', 2)->sole(); expect($draft->isPublished())->toBeFalse() ->and($draft->ram_mb)->toBe(8192) ->and($draft->priceFor('monthly')->amount_cents)->toBe(19900) ->and($draft->priceFor('yearly')->amount_cents)->toBe(238800) // A draft is not on sale, so the shop is unchanged. ->and(app(PlanCatalogue::class)->sellable()['team']['price_cents'])->toBe(17900); }); it('refuses a mistyped figure with a message instead of overflowing the column', function () { Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) // A stray keystroke on the price field: unbounded, this overflows an // unsigned int and the owner gets a 500 with no idea which number // was wrong. ->set('monthlyPrice', 'neunzehn Euro') ->set('ramMb', 999999999) ->call('draft') ->assertHasErrors(['monthlyPrice', 'ramMb']); expect(teamFamily()->versions()->count())->toBe(1); }); it('does not call a version live when the plan is withdrawn', function () { PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]); // The window is still open, but the kill switch overrides it — saying // "on sale" here would contradict what the customer is actually offered. Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) ->assertSee(__('plans.badge_withdrawn')) ->assertSee(__('plans.family_withdrawn_note')) ->assertDontSee(__('plans.badge_live')); }); it('refuses a feature key we have no label for', function () { Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) // The checkboxes only offer real keys, but a request can carry anything. ->set('features', ['managed_updates', 'free_unicorns']) ->call('draft') ->assertHasErrors('features.1'); }); it('refuses a performance class we have no label for', function () { // Published, this would be frozen and shown to customers as a raw // translation key for the life of the version. Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) ->set('performance', 'enhnaced') ->call('draft') ->assertHasErrors('performance'); }); it('refuses a disk smaller than the storage it has to hold', function () { Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]) ->set('quotaGb', 500) ->set('diskGb', 100) ->call('draft') ->assertHasErrors('diskGb'); }); it('publishes a draft into a window, and refuses one that overlaps', function () { $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->set('monthlyPrice', '199,00')->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); // Straight into the running version's window: refused, and said so on the // form rather than thrown. $page->call('choose', $draft->uuid) ->set('availableFrom', LocalTime::toField(now())) ->call('publish') ->assertHasErrors('availableFrom'); expect($draft->fresh()->isPublished())->toBeFalse(); // Close the running one first, then the successor lands cleanly. $live = app(PlanCatalogue::class)->currentVersion('team'); $page->call('close', $live->uuid); $page->call('choose', $draft->uuid) ->set('availableFrom', LocalTime::toField(now()->addMinute())) ->call('publish') ->assertHasNoErrors(); expect($draft->fresh()->isPublished())->toBeTrue() ->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHour())->version)->toBe(2); }); it('will not publish a window that ends before it starts', function () { $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); $page->call('choose', $draft->uuid) ->set('availableFrom', LocalTime::toField(now()->addWeek())) ->set('availableUntil', LocalTime::toField(now())) ->call('publish') ->assertHasErrors('availableUntil'); }); it('discards a draft through the modal, and never a published version', function () { $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]) ->call('delete'); expect(PlanVersion::query()->whereKey($draft->id)->exists())->toBeFalse(); // The published one cannot even open the dialog — customers are on it. $live = app(PlanCatalogue::class)->currentVersion('team'); Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $live->uuid]) ->assertForbidden(); }); it('does not discard a draft that was published while the dialog was open', function () { $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); // The dialog opens on a draft... $modal = Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]); // ...and someone publishes it before the button is clicked. $live = app(PlanCatalogue::class)->currentVersion('team'); app(PlanCatalogue::class)->schedule($live, $live->available_from, now()); app(PlanCatalogue::class)->publish($draft, now()); $modal->call('delete'); // Customers can be contracted to it by now, so it has to survive. expect(PlanVersion::query()->whereKey($draft->id)->exists())->toBeTrue(); }); it('authorises the modal itself, not just the page that opens it', function () { $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); $viewer = Operator::factory()->role('Support')->create(); // Modals are reachable without the page's guards. Livewire::actingAs($viewer, 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]) ->assertForbidden(); }); it('lists a withdrawn plan as withdrawn, not as missing', function () { PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]); Livewire::actingAs(owner(), 'operator')->test(Plans::class) ->assertSee('team') ->assertSee(__('plans.withdrawn_badge')); }); it('shows the console entry only to those who may use it', function () { $this->actingAs(owner(), 'operator')->get(route('admin.overview'))->assertSee(__('admin.nav.plans')); $this->actingAs(Operator::factory()->role('Support')->create(), 'operator') ->get(route('admin.overview')) ->assertDontSee(__('admin.nav.plans')); });