CluPilotCloud/tests/Feature/Admin/PlanAdminTest.php

311 lines
13 KiB
PHP

<?php
use App\Support\LocalTime;
use App\Livewire\Admin\ConfirmDeletePlanDraft;
use App\Livewire\Admin\EditPlanFamily;
use App\Livewire\Admin\PlanVersions;
use App\Livewire\Admin\Plans;
use App\Models\Operator;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
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(): 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');
$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('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'));
});