') - $start); } /** One row of that table, by its heading — the cells of a single feature. */ function priceSheetRow(string $html, string $label): string { $table = priceSheetTable($html); $at = strpos($table, '>'.$label.''); if ($at === false) { return ''; } $start = (int) strrpos(substr($table, 0, $at), '', $at) - $start); } it('quotes the price the catalogue would actually charge', function () { $team = PlanFamily::query()->where('key', 'team')->firstOrFail(); $version = PlanVersion::query()->where('plan_family_id', $team->id)->firstOrFail(); $monthly = $version->prices()->where('term', 'monthly')->firstOrFail(); $page = $this->get('/')->assertOk(); // Whole euros, German grouping, non-breaking space — the exact string the // sheet renders, so a formatting change cannot slip past this test. $page->assertSee(number_format($monthly->amount_cents / 100, 0, ',', '.')."\u{00A0}€", false); $page->assertSee('bis '.$version->seats); // The included traffic is sold and metered but was simply never printed. $page->assertSee('Datenvolumen'); $page->assertSee($version->traffic_gb >= 1000 && $version->traffic_gb % 1000 === 0 ? ($version->traffic_gb / 1000).' TB' : $version->traffic_gb.' GB'); }); it('follows the catalogue when the owner changes a price', function () { // The point of the whole exercise: one place to change a price. DB::table('plan_prices') ->where('plan_version_id', PlanVersion::query() ->where('plan_family_id', PlanFamily::query()->where('key', 'team')->value('id')) ->value('id')) ->where('term', 'monthly') ->update(['amount_cents' => 21500]); $this->get('/') ->assertOk() ->assertSee("215\u{00A0}€", false) ->assertDontSee("179\u{00A0}€", false); }); it('keeps the front page up when the catalogue cannot be read', function () { // A second version of the same plan on sale at the same moment. The // catalogue refuses to guess which one is being sold — correctly, because // guessing would mean quoting a price at random. The marketing page must // survive that: an outage in the shop is not an outage of the company. Log::spy(); $team = PlanFamily::query()->where('key', 'team')->firstOrFail(); $original = PlanVersion::query()->where('plan_family_id', $team->id)->firstOrFail(); $clone = collect($original->getAttributes())->except('id')->all(); $clone['uuid'] = (string) Str::uuid(); $clone['version'] = $original->version + 1; DB::table('plan_versions')->insert($clone); $this->get('/') ->assertOk() ->assertSee('Preise derzeit auf Anfrage') // No number survives from the broken catalogue. ->assertDontSee("179\u{00A0}€", false); Log::shouldHaveReceived('error') ->withArgs(fn (string $message) => str_contains($message, 'plan catalogue')) ->once(); }); it('names the currency the catalogue is priced in, not the one the page was written in', function () { // The currency is configurable. A sheet that prints "€" while the checkout // charges francs is the same two-sources-of-truth mistake as a hard-coded // amount — only harder to spot, because the number looks right. config()->set('provisioning.currency', 'CHF'); DB::table('plan_prices')->update(['currency' => 'CHF']); $this->get('/') ->assertOk() ->assertSee("179\u{00A0}CHF", false) ->assertDontSee("179\u{00A0}€", false); }); it('marks the plan the console recommends, and only that one', function () { DB::table('plan_families')->update(['is_recommended' => false]); DB::table('plan_families')->where('key', 'business')->update(['is_recommended' => true]); $content = $this->get('/')->assertOk()->getContent(); expect(strpos($content, '>Business<'))->toBeInt() ->and(strpos($content, 'Empfohlen'))->toBeGreaterThan(strpos($content, '>Business<')) // Singular: exactly one plan carries the mark on the page. ->and(substr_count($content, 'Empfohlen'))->toBe(1); }); it('shows the audience line the console saved, not a string compiled into the controller', function () { // The point of this migration: the console is the one place this // sentence is written, same as the price. DB::table('plan_families') ->where('key', 'team') ->update(['audience' => 'Für Redaktionen und Kanzleien']); $this->get('/') ->assertOk() ->assertSee('Für Redaktionen und Kanzleien') ->assertDontSee('Für wachsende Teams'); }); it('states what every package has once, above the table instead of as a row in it', function () { // Four rows of ticks running straight down every column, and then the same // promises again in a line under the table. Neither told a visitor anything // about the choice they were there to make. $content = $this->get('/')->assertOk()->getContent(); expect($content)->toContain('In jedem Paket enthalten') ->and($content)->toContain('Überwachung rund um die Uhr') // That the table was found at all: every "is not in the table" // assertion in this file passes for free against an empty string. ->and(priceSheetTable($content))->toContain('Eigene Domain') ->and(priceSheetTable($content))->not->toContain('Überwachung rund um die Uhr') // The prose line that used to repeat it under the table is gone with it. ->and($content)->not->toContain('In jedem Paket:'); }); it('offers what a plan lacks at the price the module catalogue would charge', function () { // The third cell state, and the reason it exists: team does not carry an // own domain, but we sell one — a dash there was the page telling a visitor // no on a sale we would happily make. $this->get('/')->assertOk()->assertSee('optional · '.priceSheetGross(900), false); // And the figure follows the catalogue. Written into the template it would // be right until the first time somebody changed the price and read the // page as confirmation. config()->set('provisioning.addons.custom_domain.price_cents', 1900); $this->get('/') ->assertOk() ->assertSee('optional · '.priceSheetGross(1900), false) ->assertDontSee('optional · '.priceSheetGross(900), false); }); it('states the own-domain rule package by package: impossible, optional, included', function () { // The owner's rule, on the page that has to say it: an own domain is // impossible on the entry package, a module on the one above it, and part // of the two above that. Quoting nine euros in the first column was the // sheet making an offer the shop would then refuse — and the price sheet is // where somebody decides which package to buy in the first place. $row = priceSheetRow($this->get('/')->assertOk()->getContent(), 'Eigene Domain'); $cells = array_slice(explode('toHaveCount(4) ->and($cells[0])->toContain('—') ->and($cells[0])->not->toContain('optional') ->and($cells[0])->not->toContain('€') ->and($cells[1])->toContain('optional · '.priceSheetGross(900)) ->and($cells[2])->toContain('inklusive') ->and($cells[3])->toContain('inklusive'); }); it('leaves a feature we do not sell as a dash, without inventing a price for it', function () { // Longer retention is not on the module list, so start does not get an // offer for it. Being able to say "no" is what makes the "optional" cells // above worth anything. $row = priceSheetRow($this->get('/')->assertOk()->getContent(), 'Verlängerte Aufbewahrung'); expect($row)->toContain('—') ->and($row)->not->toContain('optional') ->and($row)->not->toContain('€'); }); it('shows one support level per plan, and never a dash for the best of them', function () { // priority_support and premium_sla are steps on one ladder, and as two // independent rows they made the top plan look worse than the middle one: // enterprise carries the SLA and not the priority flag, so it rendered // "Bevorzugter Support —" beside team's tick. $content = $this->get('/')->assertOk()->getContent(); $row = priceSheetRow($content, 'Support'); expect($row)->toContain('Premium') ->and($row)->toContain('Standard') ->and($row)->not->toContain('—') // The two booleans are gone from the table as separate rows. ->and(priceSheetTable($content))->not->toContain('Vereinbarte Reaktionszeiten'); }); it('shows the platform address in the shape a customer will type it, under the configured zone', function () { // "Eigene CluPilot-Subdomain" as a table row was meaningless twice over: // every instance gets one, and the phrase does not say what it looks like. // Through the setting, not the config: the setting is what // ProvisioningSettings::dnsZone() reads first, and since instances moved to // their own zone there is a row in it on every installation. App\Support\Settings::set('provisioning.dns_zone', 'wolke.test'); $this->get('/') ->assertOk() ->assertSee('ihrefirma.wolke.test') ->assertDontSee('ihrefirma.clupilot.com'); }); it('names the modules it sells, with the prices the catalogue charges for them', function () { // The complaint that started this: the shop sold five modules and the // public page mentioned none of them. $storage = (int) config('provisioning.storage_addon.gb'); // Gross, because that is what the sheet quotes — a private customer must // never be shown a figure that grows at checkout. $money = fn (int $cents) => priceSheetGross($cents); $page = $this->get('/')->assertOk(); $page->assertSee('Optional dazubuchbar'); foreach (['extra_backups', 'priority_support', 'collabora_pro', 'custom_domain'] as $key) { $page->assertSee($money((int) config("provisioning.addons.{$key}.price_cents")), false); } // The storage pack is sold by the pack, so the sheet says how big one is. $page->assertSee($storage.' GB') ->assertSee($money((int) config('provisioning.storage_addon.price_cents')), false); }); it('keeps the sheet up when nothing is on sale on top of a package', function () { // An installation that sells no modules is a configuration, not a failure. // The block disappears; the cells that could have been an offer go back to // being honest dashes. config()->set('provisioning.addons', []); config()->set('provisioning.storage_addon', []); $this->get('/') ->assertOk() ->assertDontSee('Optional dazubuchbar') ->assertDontSee('optional · ', false) // The comparison itself is untouched: an own domain still tells the // packages apart, it just cannot be bought into the smaller ones. ->assertSee('Eigene Domain'); }); it('drops a catalogue feature it has no wording for, rather than printing its key', function () { $version = PlanVersion::query() ->where('plan_family_id', PlanFamily::query()->where('key', 'team')->value('id')) ->firstOrFail(); DB::table('plan_versions')->where('id', $version->id)->update([ 'features' => json_encode([...$version->features, 'quantum_entanglement']), ]); $this->get('/') ->assertOk() ->assertDontSee('quantum_entanglement'); });