CluPilotCloud/tests/Feature/Billing/CustomDomainAccessTest.php

248 lines
11 KiB
PHP

<?php
use App\Actions\BookAddon;
use App\Actions\GrantAddon;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\PlanChange;
use Livewire\Livewire;
/**
* Who may have their own domain.
*
* The domain page shipped with no access control at all: the `custom_domain`
* plan feature was checked nowhere, and every customer on every package could
* point a domain at their instance. These tests are the owner's rule, stated as
* behaviour — impossible on the entry package, a module on Team, part of the
* package on Business and Enterprise — and, as much as anything, they are about
* what happens on the way DOWN, which is where a right silently outliving the
* package that granted it does its damage.
*/
function domainCustomer(string $plan, array $instanceAttributes = []): array
{
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email, 'email_verified_at' => now()]);
$customer->update(['user_id' => $user->id]);
$instance = Instance::factory()->create([
'customer_id' => $customer->id,
'plan' => $plan,
'status' => 'active',
// Per package: subdomains are unique, and one test walks two packages.
'subdomain' => 'berger-'.$plan,
...$instanceAttributes,
]);
$subscription = Subscription::factory()->plan($plan)->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
// One test below calls Billing::purchase(), which since Task 5 refuses
// outright without a Stripe key for the active mode. Without this, that
// test would see the same "no order" result for the wrong reason — the
// Stripe guard, not the plan-restriction it names — and prove nothing.
withStripeSecret();
return [$customer, $user, $instance, $subscription];
}
/** An instance whose owner has proved the domain is theirs. */
function verifiedDomain(string $domain = 'cloud.berger.at'): array
{
return ['custom_domain' => $domain, 'domain_token' => 'tok123', 'domain_verified_at' => now()];
}
it('does not sell an own domain to the entry package, at any of the three doors', function () {
[$customer, $user, , $subscription] = domainCustomer('start');
// The action, which is the only door that actually books anything.
expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON))
->toThrow(RuntimeException::class, __('billing.custom_domain.not_in_plan', ['plan' => 'Start']));
// The page, which must not offer what the action would refuse.
$page = Livewire::actingAs($user)->test(Billing::class);
expect($page->viewData('addons'))->not->toHaveKey(CustomDomainAccess::ADDON);
$page->assertDontSee("purchase('addon', 'custom_domain')", false);
// And the component method behind the button, because a hidden button is
// markup: a stale tab can still call it.
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', CustomDomainAccess::ADDON);
expect(Order::query()->where('customer_id', $customer->id)->where('addon_key', CustomDomainAccess::ADDON)->exists())
->toBeFalse();
});
it('leaves no paid order behind when a grant of the module is refused', function () {
// The console gives a module away through the same booking action, on a
// synthetic order it writes first. A refusal after that would leave a paid
// purchase of nothing on the customer's account — and on their invoice.
[$customer, , , $subscription] = domainCustomer('start');
expect(fn () => app(GrantAddon::class)($subscription, admin(), CustomDomainAccess::ADDON, 0))
->toThrow(RuntimeException::class);
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse();
});
it('gives Team the domain only once the module is booked', function () {
[, , , $subscription] = domainCustomer('team');
$access = app(CustomDomainAccess::class);
// Bookable, and not yet booked — which is not the same as allowed.
expect($access->bookable($subscription))->toBeTrue()
->and($access->allows($subscription))->toBeFalse()
->and($access->refusal($subscription))->not->toBeNull();
app(BookAddon::class)($subscription, CustomDomainAccess::ADDON);
expect($access->allows($subscription))->toBeTrue()
->and($access->refusal($subscription))->toBeNull();
});
it('gives Business and Enterprise the domain with the package, and does not sell it to them twice', function () {
$access = app(CustomDomainAccess::class);
foreach (['business', 'enterprise'] as $plan) {
[, $user, , $subscription] = domainCustomer($plan);
expect($access->allows($subscription))->toBeTrue()
->and($access->hasBooked($subscription))->toBeFalse()
->and($access->bookable($subscription))->toBeFalse();
expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON))
->toThrow(RuntimeException::class, __('billing.custom_domain.already_included'));
// The card stays — it says what they have — but it is not a sale.
$page = Livewire::actingAs($user)->test(Billing::class);
expect($page->viewData('addons')[CustomDomainAccess::ADDON]['included'])->toBeTrue();
$page->assertSee(__('billing.addon_included'))
->assertDontSee("purchase('addon', 'custom_domain')", false);
}
});
it('switches the domain off when the package it moved to cannot have one', function () {
// Team → Start. The domain and the module both came with the package the
// customer has just left.
[$customer, , $instance] = domainCustomer('team', verifiedDomain());
$start = Subscription::factory()->plan('start')->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
// The module rides along with the change: nothing cancels a booking when a
// package changes, so settling has to be the thing that notices.
SubscriptionAddon::create([
'subscription_id' => $start->id,
'addon_key' => CustomDomainAccess::ADDON,
'price_cents' => 900,
'currency' => 'EUR',
'quantity' => 1,
'booked_at' => now(),
]);
expect(PlanChange::settleCustomDomain($start))->toBeTrue();
$instance->refresh();
expect($instance->domainIsVerified())->toBeFalse()
// Back to the address the platform issued it, which is the whole of the
// fallback: nothing here reimplements how an instance is served.
->and($instance->address('clupilot.com'))->toBe('berger-team.clupilot.com')
->and($instance->custom_domain)->toBeNull()
// And it stops being charged for. A feature withdrawn while the module
// is still on the bill is the complaint we would deserve.
->and($start->addons()->active()->count())->toBe(0);
});
it('asks a Business customer before the move, and abides by their answer', function () {
[$customer, $user, $instance] = domainCustomer('business', verifiedDomain());
$team = app(PlanCatalogue::class)->sellable()['team'];
$check = DowngradeCheck::for($customer, $instance, $team, 'team');
// Possible, and still worth saying out loud beforehand.
expect($check->allowed)->toBeTrue()
->and($check->consequences)->toHaveCount(1)
->and($check->consequences[0]['key'])->toBe('custom_domain_addon')
->and($check->consequences[0]['replace']['domain'])->toBe('cloud.berger.at');
// And it is on the card, above the button that would do it — not in a
// message afterwards.
Livewire::actingAs($user)->test(Billing::class)
->assertSee(__('billing.downgrade_consequence_title'))
->assertSee('cloud.berger.at');
// Declining: the Team contract carries no module, and the domain goes.
$teamContract = Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
expect(PlanChange::settleCustomDomain($teamContract))->toBeTrue()
->and($instance->fresh()->domainIsVerified())->toBeFalse();
});
it('leaves everything alone when the customer keeps the domain by booking the module', function () {
[$customer, , $instance] = domainCustomer('business', verifiedDomain());
// The module is booked onto the contract they are moving TO — on Business
// it would be refused as something they already have.
$teamContract = Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
app(BookAddon::class)($teamContract, CustomDomainAccess::ADDON);
expect(PlanChange::settleCustomDomain($teamContract))->toBeFalse()
->and($instance->fresh()->domainIsVerified())->toBeTrue()
->and($instance->fresh()->address('clupilot.com'))->toBe('cloud.berger.at');
// And with the module booked there is nothing left to warn about.
$team = app(PlanCatalogue::class)->sellable()['team'];
expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]);
});
it('says nothing about a domain the customer has not got', function () {
// The warning is about a concrete address being switched off. Without one
// there is nothing to decide, and a paragraph of consequences on every
// downgrade card teaches people to skip them.
[$customer, , $instance] = domainCustomer('business');
$team = app(PlanCatalogue::class)->sellable()['team'];
expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]);
});
it('keeps the rule in one place, where it can only be changed once', function () {
// The gap this closed was the same rule living in no place at all. The
// opposite failure — a copy in the shop, a copy on the price sheet, a copy
// in the downgrade warning — is the one that ships a customer three
// different answers, so the config key that says where a domain is
// impossible is read by exactly one class.
$readers = [];
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(app_path()));
foreach ($files as $file) {
if ($file->isFile() && $file->getExtension() === 'php'
&& str_contains((string) file_get_contents($file->getPathname()), 'unavailable_on')) {
$readers[] = str_replace(base_path().'/', '', $file->getPathname());
}
}
expect($readers)->toBe(['app/Services/Billing/CustomDomainAccess.php']);
});