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'); });