292 lines
12 KiB
PHP
292 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Models\PlanFamily;
|
|
use App\Models\PlanVersion;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* The public price sheet reads the catalogue.
|
|
*
|
|
* It used to be written into the page by hand, and the two had already drifted:
|
|
* the site advertised 249 € for a plan the checkout charged 399 € for. These
|
|
* tests exist so that cannot come back — and so a catalogue that is broken
|
|
* takes the price sheet down, never the whole front page.
|
|
*
|
|
* The sheet is three blocks — what every package has, what tells them apart,
|
|
* what costs extra — and the tests below are mostly about the seams between
|
|
* them: the same words mean different things in each block, so a promise made
|
|
* once above the table must not reappear as a row inside it, and a cell that
|
|
* says nothing must be distinguishable from one that says "nine euros".
|
|
*/
|
|
|
|
/**
|
|
* The comparison table's own markup.
|
|
*
|
|
* A search across the whole page cannot tell the blocks apart — "Überwachung
|
|
* rund um die Uhr" is a promise above the table and a card bullet beside it,
|
|
* and the point of the rebuild is that it is no longer a row inside it.
|
|
*/
|
|
function priceSheetTable(string $html): string
|
|
{
|
|
$section = substr($html, (int) strpos($html, 'id="preise"'));
|
|
$start = strpos($section, '<table');
|
|
|
|
if ($start === false) {
|
|
return '';
|
|
}
|
|
|
|
return substr($section, $start, (int) strpos($section, '</table>') - $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.'</th>');
|
|
|
|
if ($at === false) {
|
|
return '';
|
|
}
|
|
|
|
$start = (int) strrpos(substr($table, 0, $at), '<tr');
|
|
|
|
return substr($table, $start, (int) strpos($table, '</tr>', $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 · 9\u{00A0}€", 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 · 19\u{00A0}€", false)
|
|
->assertDontSee("optional · 9\u{00A0}€", 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('<td', $row), 1);
|
|
|
|
expect($cells)->toHaveCount(4)
|
|
->and($cells[0])->toContain('—')
|
|
->and($cells[0])->not->toContain('optional')
|
|
->and($cells[0])->not->toContain('€')
|
|
->and($cells[1])->toContain("optional · 9\u{00A0}€")
|
|
->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.
|
|
config()->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');
|
|
$money = fn (int $cents) => number_format($cents / 100, 0, ',', '.')."\u{00A0}€";
|
|
|
|
$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');
|
|
});
|