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 straight over the running version, announcing the handover first', 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(); $live = app(PlanCatalogue::class)->currentVersion('team'); $at = now()->addHour(); // Publishing ends another version's sale, so the form has to say which one // and when — before the button is pressed, not in the list afterwards. $page->call('choose', $draft->uuid) ->set('availableFrom', LocalTime::toField($at)) ->assertSee(__('plans.handover_note', [ 'version' => $live->version, 'at' => $at->copy()->local()->isoFormat('L LT'), ])); // No closing the running version first: that was the trap. A replacement // that then failed to publish left the plan off sale with no way back. $page->call('publish')->assertHasNoErrors(); expect($draft->fresh()->isPublished())->toBeTrue() ->and($live->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue() ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(1) ->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHours(2))->version)->toBe(2); }); it('puts a closed version back on sale from the version list', function () { $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $live = app(PlanCatalogue::class)->currentVersion('team'); $page->call('close', $live->uuid); expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse(); // Closing was one-way until now, so the way back has to be on the page. $page->assertSee(__('plans.reopen')) ->call('reopen', $live->uuid) ->assertHasNoErrors(); expect(app(PlanCatalogue::class)->isSellable('team'))->toBeTrue() ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe($live->version) ->and($live->fresh()->available_until)->toBeNull(); }); it('refuses to reopen a version whose successor is selling, in the console\'s own words', 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(); $live = app(PlanCatalogue::class)->currentVersion('team'); $page->call('choose', $draft->uuid) ->set('availableFrom', LocalTime::toField(now())) ->call('publish') ->assertHasNoErrors(); $page->call('reopen', $live->uuid) ->assertHasErrors('availableFrom') ->assertSee(__('plans.reopen_blocked')) // The catalogue explains itself in English, at the length a log entry // deserves. That sentence must not reach a German console. ->assertDontSee('overlaps'); expect($live->fresh()->available_until)->not->toBeNull() ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(2); }); it('will not reopen a draft, and says why instead of doing nothing', function () { $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); // The list offers no such button for a draft, but a request can carry // anything, and "was never on sale" is a different refusal from "something // newer is selling". $page->call('reopen', $draft->uuid) ->assertHasErrors('availableFrom') ->assertSee(__('plans.reopen_draft')); expect($draft->fresh()->isPublished())->toBeFalse() ->and($draft->fresh()->available_until)->toBeNull(); }); it('re-checks the capability on reopen rather than trusting the mount', function () { $user = owner(); $live = app(PlanCatalogue::class)->currentVersion('team'); app(PlanCatalogue::class)->schedule($live, $live->available_from, now()); $page = Livewire::actingAs($user, 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); // The page is open and legitimate, and then the account is demoted. $user->syncRoles(['Support']); app(PermissionRegistrar::class)->forgetCachedPermissions(); $page->call('reopen', $live->uuid)->assertForbidden(); expect($live->fresh()->available_until)->not->toBeNull() ->and(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse(); }); 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('recommends a plan, and recommending a second one clears the first', function () { $team = teamFamily(); $business = PlanFamily::query()->where('key', 'business')->sole(); Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => $team->uuid]) ->set('recommended', true) ->call('save'); expect($team->fresh()->is_recommended)->toBeTrue(); // "Recommended" is singular by nature: marking a second plan is not two // recommendations, it is none — so the first must clear the moment the // second is set. Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => $business->uuid]) ->set('recommended', true) ->call('save'); expect($business->fresh()->is_recommended)->toBeTrue() ->and($team->fresh()->is_recommended)->toBeFalse(); }); it('opens the modal already showing the current mark, and can clear it entirely', function () { $team = teamFamily(); PlanFamily::query()->whereKey($team->id)->update(['is_recommended' => true]); Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => $team->uuid]) ->assertSet('recommended', true) ->set('recommended', false) ->call('save'); expect($team->fresh()->is_recommended)->toBeFalse(); }); it('saves the audience line and note beside the plan', function () { Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => teamFamily()->uuid]) ->set('audience', 'Für Redaktionen') ->set('note', 'Testnotiz') ->call('save'); expect(teamFamily()) ->audience->toBe('Für Redaktionen') ->note->toBe('Testnotiz'); }); it('closes the marketing modal to operators without the capability', function () { // Modals are reachable without the page's guards — same reason // ConfirmDeletePlanDraft checks this on itself. Livewire::actingAs(Operator::factory()->role('Support')->create(), 'operator') ->test(EditPlanFamily::class, ['uuid' => teamFamily()->uuid]) ->assertForbidden(); }); 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')); });