CluPilotCloud/tests/Feature/SelfServiceOrderTest.php

181 lines
7.0 KiB
PHP

<?php
use App\Models\Customer;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\DB;
/**
* A customer buys a package themselves.
*
* Every path on the site used to end in "anfragen" — a mailto: link and an
* afternoon of somebody's time per customer, for a product whose whole point is
* that the machine does the work. Now: sign in, pick, pay, and provisioning
* starts on the webhook. The one case that still needs a person is no capacity,
* and it needs them to buy a server, not to answer a mail.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
// Every sellable price needs a Stripe id, or there is nothing to check out
// against. The catalogue sync fills these in production.
DB::table('plan_prices')->update(['stripe_price_id' => 'price_test']);
});
/** A signed-in, verified portal user. */
function buyer(): User
{
return User::factory()->create(['email_verified_at' => now()]);
}
it('opens a Stripe checkout for the package that was chosen', function () {
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'start'])
->assertRedirect();
expect($this->stripe->checkouts)->toHaveCount(1)
->and($this->stripe->checkouts[0]['price'])->toBe('price_test');
});
it('carries exactly the metadata the webhook reads back', function () {
// plan, plan_version_id and datacenter are the contract between the
// checkout and StripeWebhookController. A missing key does not fail — it
// silently builds the wrong package.
$this->actingAs(buyer())->post(route('checkout.start'), ['plan' => 'team']);
$meta = $this->stripe->checkouts[0]['metadata'];
expect($meta)->toHaveKeys(['plan', 'plan_version_id', 'datacenter'])
->and($meta['plan'])->toBe('team')
->and($meta['plan_version_id'])->not->toBeEmpty();
});
it('sends the customer to Stripe with the address their credentials will go to', function () {
$user = buyer();
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'start']);
expect($this->stripe->checkouts[0]['email'])->toBe($user->email);
});
it('never lets a request name its own price', function () {
// The plan key comes from a form, and a form field is a string somebody can
// retype. What is charged is the catalogue's figure for the version on sale
// — nothing in the request may decide it.
$this->actingAs(buyer())->post(route('checkout.start'), [
'plan' => 'start',
'amount_cents' => 1,
'price' => 'price_of_my_choosing',
]);
expect($this->stripe->checkouts[0]['price'])->toBe('price_test');
});
it('refuses a plan that is not on sale', function () {
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'does-not-exist'])
->assertSessionHasErrors('plan');
expect($this->stripe->checkouts)->toBeEmpty();
});
it('refuses a plan that was never synced to Stripe', function () {
// A price with no Stripe id would open a checkout for nothing.
PlanPrice::query()->update(['stripe_price_id' => null]);
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'start'])
->assertSessionHasErrors('plan');
expect($this->stripe->checkouts)->toBeEmpty();
});
it('says so rather than redirecting into a broken checkout when Stripe is not connected', function () {
$this->stripe->configured = false;
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'start'])
->assertSessionHasErrors('plan');
expect($this->stripe->checkouts)->toBeEmpty();
});
it('sends an existing customer to the plan change instead of selling a second cloud', function () {
// A second checkout would build a second, empty cloud and bill for both.
// Moving up is a plan change: it prorates and the data stays where it is.
$user = buyer();
$customer = Customer::factory()->create(['email' => $user->email]);
Subscription::factory()->create(['customer_id' => $customer->id, 'status' => 'active']);
$this->actingAs($user)
->post(route('checkout.start'), ['plan' => 'team'])
->assertRedirect(route('billing'));
expect($this->stripe->checkouts)->toBeEmpty();
});
it('lets nobody buy anything without signing in first', function () {
// The credentials for the finished cloud are mailed to the account's
// address, so the account has to exist and be confirmed before the money
// moves — not afterwards.
$this->post(route('checkout.start'), ['plan' => 'start'])->assertRedirect(route('login'));
$this->get(route('order'))->assertRedirect(route('login'));
expect($this->stripe->checkouts)->toBeEmpty();
});
it('does not take an unverified address through checkout', function () {
$unverified = User::factory()->create(['email_verified_at' => null]);
$this->actingAs($unverified)->post(route('checkout.start'), ['plan' => 'start'])
->assertRedirect(route('verification.notice'));
expect($this->stripe->checkouts)->toBeEmpty();
});
it('shows the packages with the same gross prices the public sheet quotes', function () {
// A customer quoted 58,80 € out there must not meet 49 € on the page with
// the button on it.
$this->actingAs(buyer())
->get(route('order'))
->assertOk()
->assertSee('58,80')
->assertSee(__('order.buy'));
});
it('promises delivery on the booking page from the estate, like everywhere else', function () {
$this->actingAs(buyer())
->get(route('order'))
->assertOk()
// No host is onboarded in this test, so nothing can be immediate.
->assertSee(__('delivery.scheduled'))
->assertDontSee(__('delivery.immediate'));
});
it('points the website buttons at the sign-in rather than an inbox', function () {
// Booking happens in the panel, where everything already works. The website
// does not need a second checkout — it needs to get somebody to the door.
$content = $this->get('/')->assertOk()->getContent();
expect($content)->toContain(route('login'))
// Not straight at /order: that is behind auth, so an anonymous visitor
// would be bounced by a middleware redirect from a page they never
// asked for.
->and($content)->not->toContain('href="'.route('order').'"')
// And not back to the old shape: every card ending in a jump to a
// mailto: block.
->and($content)->not->toContain('anfragen</x-ui.button>');
});
it('does not promise a migration it has not looked at', function () {
// We do not know where a visitor's data is, what shape it is in, or whether
// it can be moved at all. "danach wissen Sie … wie Ihre Daten sicher
// umziehen" was a promise made before seeing the thing it is about.
expect($this->get('/')->getContent())
->not->toContain('wie Ihre Daten sicher umziehen');
});