CluPilotCloud/tests/Feature/Billing/StoragePackLimitTest.php

233 lines
10 KiB
PHP

<?php
use App\Actions\BookAddon;
use App\Actions\OpenSubscription;
use App\Livewire\Billing;
use App\Livewire\ConfirmBookStorage;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
/**
* Höchstens drei Blöcke.
*
* Der Deckel liegt dort, wo Aufsteigen billiger wird: Start + 3 Blöcke sind
* 90 GB für 84 €, Team ist 85 GB für 79 € mit mehr Nutzern und mehr RAM. Er
* gehört in die Aktion und nicht ins Formular — dieselbe Lektion wie bei
* ReissueTakeover (v1.3.82): die Ansicht versteckte den Knopf, die Methode
* prüfte nichts, und Codex fand es zweimal.
*/
function limitContract(string $plan = 'start'): Subscription
{
$order = Order::factory()->create(['plan' => $plan, 'datacenter' => 'fsn', 'status' => 'paid']);
return app(OpenSubscription::class)($order);
}
/** The signed-in user behind a contract's customer, for the Livewire tests below. */
function limitUser(Subscription $subscription): User
{
return User::factory()->create(['email' => $subscription->customer->email]);
}
/** Eine bezahlte Speicher-Bestellung dieses Kunden — eine Buchung hat eine. */
function limitOrder(Subscription $subscription): Order
{
return Order::factory()->create([
'customer_id' => $subscription->customer_id, 'type' => 'addon', 'addon_key' => 'storage', 'status' => 'paid',
]);
}
it('lehnt den vierten Block auf einmal ab', function () {
$subscription = limitContract();
expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 4))
->toThrow(RuntimeException::class);
expect($subscription->addons()->count())->toBe(0);
});
it('zählt drei einzelne Buchungen als drei', function () {
$subscription = limitContract();
foreach (range(1, 3) as $ignored) {
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1, Order::factory()->create([
'customer_id' => $subscription->customer_id, 'type' => 'addon', 'addon_key' => 'storage', 'status' => 'paid',
]));
}
expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1, Order::factory()->create([
'customer_id' => $subscription->customer_id, 'type' => 'addon', 'addon_key' => 'storage', 'status' => 'paid',
])))->toThrow(RuntimeException::class);
expect((int) $subscription->addons()->active()->sum('quantity'))->toBe(3);
});
it('zählt eine gekündigte Buchung nicht mit', function () {
$subscription = limitContract();
$addon = app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 3);
app(BookAddon::class)->cancel($addon);
expect(app(AddonCatalogue::class)->bookableQuantity($subscription->refresh(), AddonCatalogue::STORAGE))->toBe(3);
});
it('sagt, wie viele noch gehen', function () {
$subscription = limitContract();
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 2);
expect(app(AddonCatalogue::class)->bookableQuantity($subscription->refresh(), AddonCatalogue::STORAGE))->toBe(1);
});
/**
* Nachtrag aus der Durchsicht: der Deckel hielt in BookAddon, aber der
* Warenkorb kannte ihn nur halb.
*
* `purchase()` klemmte $lines mit `max(1, min(..., bookableQuantity, ...))` —
* bei erreichtem Deckel (bookableQuantity = 0) floort das `max(1, 0)` auf
* genau eine Bestellzeile statt auf keine. Der Knopf, der `purchase('storage')`
* ohne Menge aufruft, ist in der Ansicht bedingungslos gerendert, also
* entstand am Deckel weiterhin ein bezahlbarer Auftrag, den BookAddon nach
* der Zahlung nur noch ablehnen konnte — während OrderObserver den
* Fehlschlag abfängt und der Auftrag `paid` stehen bleibt. Genau das Szenario,
* dessentwegen diese Aufgabe existiert, nur auf den Fall "genau am Deckel"
* verengt statt beseitigt.
*/
it('legt am erreichten Deckel keine Bestellzeile mehr an', function () {
$subscription = limitContract();
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 3);
withStripeSecret();
Livewire::actingAs(limitUser($subscription))
->test(Billing::class)
->call('purchase', 'storage');
expect(Order::where('customer_id', $subscription->customer_id)->where('type', 'storage')->count())->toBe(0);
});
/**
* Nachtrag aus der Durchsicht: das Bestätigungs-Modal klemmte an der
* absoluten Grenze (3), nicht an der Restmenge. Die Karte, die das Modal
* öffnet, kennt nur die Zahl, die einen Downgrade-Blocker aufheben würde —
* nicht, dass der Vertrag schon einen Block hält. Ohne diese Klemmung hätte
* der Dialog "3 Pakete" versprochen, während purchase() danach nur die
* tatsächlich noch buchbaren 2 in den Warenkorb gelegt hätte: Dialogtext und
* wirkliche Bestellmenge liefen auseinander.
*/
it('klemmt das Bestätigungs-Modal an der Restmenge, nicht an der absoluten Grenze', function () {
$subscription = limitContract();
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1);
Livewire::actingAs(limitUser($subscription))
->test(ConfirmBookStorage::class, ['packs' => 3])
->assertSet('packs', 2)
->assertSee(__('billing.storage_confirm_title', ['count' => 2]))
->call('proceed')
->assertDispatched('storage-packs-confirmed', packs: 2);
});
/**
* Nachtrag aus der Durchsicht (Befund 1): der Deckel stand außerhalb der
* Transaktion, und für Speicher wurde der Vertrag gar nicht gesperrt.
*
* Zwei Speicher-Bestellungen desselben Vertrags, gleichzeitig verarbeitet,
* lasen damit beide denselben alten Stand und fügten beide ein — der eindeutige
* Index über (order_id, addon_key) greift nicht, weil es zwei verschiedene
* Bestellungen sind. Ergebnis: ein Vertrag über dem Deckel.
*
* WAS DIESER TEST BEWEIST: dass der Vertrag auch für ein Mengen-Modul gesperrt
* wird, dass die Zählung NACH dieser Sperre kommt und dass beide innerhalb der
* Buchungstransaktion laufen — die Reihenfolge, ohne die eine Sperre nichts
* bewirkt.
*
* WAS ER NICHT BEWEIST: dass zwei gleichzeitige Buchungen einander wirklich
* ausschließen. Die Suite läuft auf SQLite, dort ist `lockForUpdate()` ein
* No-op (SQLiteGrammar::compileLock() gibt '' zurück) und ein zweiter Prozess
* ist im selben PHP-Aufruf nicht herstellbar. Echte Nebenläufigkeit hängt an
* MariaDB und ist hier nicht nachgestellt; nachgewiesen wird die
* Voraussetzung dafür, nicht die Wirkung.
*/
it('zählt den Deckel unter der Vertragssperre und in derselben Transaktion, die bucht', function () {
$subscription = limitContract();
$order = limitOrder($subscription);
// RefreshDatabase hält den ganzen Test in einer Transaktion. Die Tiefe VOR
// dem Aufruf ist deshalb der Nullpunkt, gegen den "innerhalb der
// Buchungstransaktion" gemessen wird — absolut wäre 1 schon außerhalb.
$baseline = DB::transactionLevel();
$log = [];
DB::listen(function ($query) use (&$log) {
$log[] = ['sql' => $query->sql, 'level' => DB::transactionLevel()];
});
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1, $order);
// Die Sperre ist die erste Anweisung der Transaktion und die einzige
// Abfrage auf `subscriptions` in diesem Pfad; gezählt wird über
// sum(quantity) auf den Buchungen.
$lock = collect($log)->search(fn (array $statement) => str_contains($statement['sql'], 'from "subscriptions"'));
$count = collect($log)->search(fn (array $statement) => str_contains($statement['sql'], 'sum("quantity")'));
expect($lock)->not->toBeFalse()
->and($count)->not->toBeFalse()
->and($count)->toBeGreaterThan($lock)
->and($log[$lock]['level'])->toBeGreaterThan($baseline)
->and($log[$count]['level'])->toBeGreaterThan($baseline);
});
/**
* Und die andere Hälfte derselben Verschiebung: der Kurzschluss bleibt vorn.
*
* Dieselbe Lektion wie bei der Kapazitätsprüfung eine Runde zuvor (siehe
* StoragePackCapacityTest): Stripe stellt denselben Webhook nach einem Timeout
* ein zweites Mal zu. Läge die Mengenprüfung vor dem Kurzschluss, würde die
* Wiederholung fragen "passt NOCH ein Block drauf" — obwohl gar keiner
* hinzukommt — und einen längst bezahlten, längst gebuchten Vorgang genau dann
* mit einem Fehler quittieren, wenn die Buchung selbst den Deckel erreicht hat.
*/
it('gibt bei einem wiederholten Webhook die vorhandene Buchung zurück, auch am erreichten Deckel', function () {
$subscription = limitContract();
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 2, limitOrder($subscription));
$order = limitOrder($subscription);
// Der dritte Block füllt den Deckel genau aus.
$first = app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1, $order);
$second = app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1, $order);
expect($second->id)->toBe($first->id)
->and((int) $subscription->addons()->active()->sum('quantity'))->toBe(3);
});
/**
* Nachtrag aus der Durchsicht (Befund 3): dieselbe `max(1, …)`-Falle, die in
* purchase() schon behoben war, hatte im Fenster überlebt.
*
* Bei null buchbaren Blöcken machte `max(1, min($max, $packs))` daraus wieder
* einen — das Fenster versprach einen Block und schickte eine Bestellung los,
* die purchase() danach ablehnt. Ein Knopf, der etwas anbietet, das die Aktion
* dahinter ablehnt, ist eine Falle.
*/
it('bietet am erreichten Deckel keinen Kauf aus dem Bestätigungsfenster an', function () {
$subscription = limitContract();
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 3);
Livewire::actingAs(limitUser($subscription))
->test(ConfirmBookStorage::class, ['packs' => 1])
->assertSet('packs', 0)
// Keine Kaufzusage, sondern die Absage in dem Satz, den der Kunde
// überall sonst zu dieser Grenze liest.
->assertDontSee(__('billing.storage_confirm_cta'))
->assertSee(__('billing.addon_limit_reached', [
'module' => app(AddonCatalogue::class)->name(AddonCatalogue::STORAGE),
'max' => 3,
]))
->call('proceed')
->assertNotDispatched('storage-packs-confirmed');
});