233 lines
10 KiB
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');
|
|
});
|