391 lines
17 KiB
PHP
391 lines
17 KiB
PHP
<?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'));
|
||
});
|