CluPilotCloud/tests/Feature/Admin/PlanAdminTest.php

257 lines
10 KiB
PHP

<?php
use App\Livewire\Admin\ConfirmDeletePlanDraft;
use App\Livewire\Admin\PlanVersions;
use App\Livewire\Admin\Plans;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Billing\PlanCatalogue;
use Livewire\Livewire;
/**
* 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(): User
{
return User::factory()->operator()->create();
}
function teamFamily(): PlanFamily
{
return PlanFamily::query()->where('key', 'team')->sole();
}
it('is closed to operators without the capability', function () {
$viewer = User::factory()->operator('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)->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)->get(route('admin.plans'))->assertForbidden();
$this->actingAs($viewer)->get(route('admin.plans.versions', teamFamily()->uuid))->assertForbidden();
$this->actingAs(owner())->get(route('admin.plans'))->assertOk();
});
it('creates a plan line, and refuses a key that is not an identifier', function () {
Livewire::actingAs(owner())->test(Plans::class)
->set('key', 'Agentur Paket')
->set('name', 'Agentur')
->set('tier', 5)
->call('save')
->assertHasErrors('key');
Livewire::actingAs(owner())->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())->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())->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())->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('monthlyCents', 17900);
$page->set('monthlyCents', 19900)->set('yearlyCents', 238800)->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())->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('monthlyCents', 1790019900)
->set('ramMb', 999999999)
->call('draft')
->assertHasErrors(['monthlyCents', '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())->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())->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())->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())->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())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->set('monthlyCents', 19900)->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', now()->format('Y-m-d\TH:i'))
->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', now()->addMinute()->format('Y-m-d\TH:i'))
->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())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$page->call('choose', $draft->uuid)
->set('availableFrom', now()->addWeek()->format('Y-m-d\TH:i'))
->set('availableUntil', now()->format('Y-m-d\TH:i'))
->call('publish')
->assertHasErrors('availableUntil');
});
it('discards a draft through the modal, and never a published version', function () {
$page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
Livewire::actingAs(owner())->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())->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())->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())->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())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$viewer = User::factory()->operator('Support')->create();
// Modals are reachable without the page's guards.
Livewire::actingAs($viewer)->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())->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())->get(route('admin.overview'))->assertSee(__('admin.nav.plans'));
$this->actingAs(User::factory()->operator('Support')->create())
->get(route('admin.overview'))
->assertDontSee(__('admin.nav.plans'));
});