CluPilotCloud/tests/Feature/CheckoutPageTest.php

141 lines
6.0 KiB
PHP

<?php
use App\Models\Customer;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Billing\SetupFee;
use App\Support\Settings;
use Illuminate\Support\Facades\File;
/**
* The order, before it is placed.
*
* There was no such page. The order page posted straight to Stripe: the last
* thing a customer saw in this product's design was a grid of packages, and the
* next was somebody else's card form. What they were buying, what it costs in
* total, what happens afterwards and what they were agreeing to were never
* stated in one place — and the terms box sat at the TOP of the package list,
* as a condition of looking at prices.
*/
function checkoutUser(): User
{
return User::factory()->create(['email_verified_at' => now()]);
}
it('states the package, the term and every figure that will be charged', function () {
$page = $this->actingAs(checkoutUser())
->get(route('checkout', ['plan' => 'team', 'term' => 'monthly']))
->assertOk();
$page->assertSee('Team')
->assertSee(__('order.term_monthly'))
// Net, tax and gross: 179,00 net, 20 % VAT, 214,80 to pay.
->assertSee('179,00')
->assertSee('214,80')
->assertSee(__('checkout.total_today'))
// And what comes after the first charge, which is not the same figure
// whenever a setup fee is on the first one. Asserted in two pieces: the
// formatter puts a non-breaking space before the sign, and building the
// expected string by hand is a test that checks its own typing.
->assertSee(mb_substr(__('checkout.then_monthly', ['amount' => '']), 0, 6));
});
it('adds the setup fee to what is due today and to nothing after it', function () {
Settings::set('company.setup_fee_cents', 9900);
$page = $this->actingAs(checkoutUser())
->get(route('checkout', ['plan' => 'team', 'term' => 'monthly']))
->assertOk();
// 214,80 for the month plus 118,80 for the setup — both gross.
$page->assertSee(__('checkout.setup_line'))
// 214,80 for the month plus 118,80 for the setup.
->assertSee('333,60')
// The recurring figure alone is what follows the first charge.
->assertSee('214,80');
expect(SetupFee::netCents())->toBe(9900);
});
it('refuses to place the order without the terms, whatever the browser did', function () {
// The button is disabled until the box is ticked, and disabling a button in
// a browser refuses nothing. This is the rule.
$this->actingAs(checkoutUser())
->post(route('checkout.start'), ['plan' => 'team', 'term' => 'monthly'])
->assertSessionHasErrors('terms_accepted');
});
it('puts the acceptance under the total and over the button', function () {
// The order it is read in: what you get, what it costs, what you agree to,
// then the button. It used to be first, above the prices.
$page = File::get(resource_path('views/livewire/checkout.blade.php'));
expect(strpos($page, "__('checkout.total_today')"))
->toBeLessThan(strpos($page, "__('order.terms_label'"))
->and(strpos($page, "__('order.terms_label'"))
->toBeLessThan(strpos($page, "__('checkout.pay_cta')"));
});
it('says what happens after the button, because the next screen is a stranger', function () {
// Being handed to a payment provider is the moment a first-time buyer wants
// to know what they are getting into.
$this->actingAs(checkoutUser())
->get(route('checkout', ['plan' => 'start']))
->assertOk()
->assertSee(__('checkout.step_pay_title'))
->assertSee(__('checkout.step_build_title'))
->assertSee(__('checkout.step_credentials_title'))
->assertSee(__('checkout.stripe_note'));
});
it('answers a package nobody sells with a sentence, not a stack trace', function () {
// Reached from a retyped address, or from a package taken off sale between
// the two pages.
$this->actingAs(checkoutUser())
->get(route('checkout', ['plan' => 'gibt-es-nicht']))
->assertOk()
->assertSee(__('checkout.unknown_plan'));
});
it('sends a customer who already has a contract to their billing page', function () {
// A second checkout would build a second, empty cloud and bill for both.
// The order page says so; a link straight into this page skips that.
$user = checkoutUser();
$customer = Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]);
Subscription::factory()->create(['customer_id' => $customer->id, 'status' => 'active']);
$this->actingAs($user)
->get(route('checkout', ['plan' => 'team']))
->assertOk()
->assertSee(__('order.already_customer'))
->assertDontSee(__('checkout.pay_cta'));
});
it('is behind the same door as everything else', function () {
// An unconfirmed address is not a billing detail to be caught at checkout:
// the finished cloud's credentials are sent to it.
$this->get(route('checkout', ['plan' => 'team']))->assertRedirect();
$this->actingAs(User::factory()->unverified()->create())
->get(route('checkout', ['plan' => 'team']))
->assertRedirect(route('verification.notice'));
});
// ---- The mark in the mails ----
it('draws the mark in the mail header, not an empty orange tile', function () {
// It shipped as a plain square: the ascent inside the mark lives in an SVG
// the site loads, and a mail cannot — Gmail strips inline SVG, Outlook
// renders with Word, and this layout carries no image by design. So the
// shape is a character every mail font has.
$html = (string) app(App\Services\Mail\MailPreviews::class)->make('verify-email')?->render();
// As the entity it is written with: a mail is markup, and &#9650; is what
// survives every client's encoding guesswork.
expect($html)->toContain('&#9650;')
->and($html)->toContain('#f97316')
// And the whole name beside it, as the wordmark writes it.
->and($html)->toContain('CluPilot')
->and($html)->toContain('Cloud');
});