300 lines
12 KiB
PHP
300 lines
12 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('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'));
|
|
});
|