CluPilotCloud/tests/Feature/Admin/PlanAdminTest.php

391 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Livewire\Admin\ConfirmDeletePlanDraft;
use App\Livewire\Admin\EditPlanFamily;
use App\Livewire\Admin\Plans;
use App\Livewire\Admin\PlanVersions;
use App\Models\Operator;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Support\LocalTime;
use Livewire\Livewire;
use Spatie\Permission\PermissionRegistrar;
/**
* The console is where the owner creates and schedules plans. Everything the
* catalogue refuses has to be refused here too, visibly — an admin page that
* lets someone try and then throws is worse than one that explains.
*/
function owner(): Operator
{
return Operator::factory()->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');
// The monthly price and the free months. The yearly total is derived from
// them — two free means ten months paid, 199,00 × 10 — because a typed total
// left nothing knowing WHY it was that figure, and no page could then say
// "zwei Monate gratis" without working it out a second time by hand.
$page->set('monthlyPrice', '199,00')->set('freeMonths', 2)->call('draft')->assertHasNoErrors();
$draft = teamFamily()->versions()->where('version', 2)->sole();
expect($draft->isPublished())->toBeFalse()
->and($draft->ram_mb)->toBe(8192)
->and($draft->free_months)->toBe(2)
->and($draft->priceFor('monthly')->amount_cents)->toBe(19900)
->and($draft->priceFor('yearly')->amount_cents)->toBe(199000)
// 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'));
});