stripe = new FakeStripeClient; app()->instance(StripeClient::class, $this->stripe); }); it('records the mode its objects were created in', function () { OperatingMode::set(OperatingMode::Test); $this->artisan('stripe:sync-catalogue')->assertSuccessful(); expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test) ->and(StripeCatalogueMode::hasStoredObjects())->toBeTrue(); }); it('records nothing on a dry run, which creates nothing', function () { OperatingMode::set(OperatingMode::Test); $this->artisan('stripe:sync-catalogue', ['--dry-run' => true])->assertSuccessful(); expect(StripeCatalogueMode::recorded())->toBeNull(); }); it('refuses to work into a catalogue that belongs to the other account', function () { OperatingMode::set(OperatingMode::Test); $this->artisan('stripe:sync-catalogue')->assertSuccessful(); OperatingMode::set(OperatingMode::Live); $this->artisan('stripe:sync-catalogue')->assertFailed(); // Und die Zeile lügt danach nicht: der Katalog gehört weiterhin dem // Testkonto, weil dieser Lauf nichts angelegt hat. expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test); }); /** * „Herkunft nie festgehalten" ist NICHT „fremdes Konto". * * Der Zustand entsteht ohne jeden Fehler: `CheckoutController` → `PlanPrices:: * ensure()` und `BookAddon` → `SyncStripeAddonItems` → `AddonPrices::ensure()` * legen fehlende Preise selbst an, und keiner der beiden ruft `record()`. Er * entsteht auch nach einem Lauf, der mittendrin abgebrochen ist. * * Die Objekte stammen dann aus genau dem geltenden Konto. Diesen Zustand in die * Konto-Weigerung zu schicken, hieße einem Betreiber das Leeren eines Katalogs * aufzutragen, an dem laufende Verträge abgerechnet werden — für ein * Wiederaufsetzen, für das die Idempotenzschlüssel dieses Befehls ausdrücklich * gebaut sind. */ it('takes up a catalogue whose origin was never written down', function () { OperatingMode::set(OperatingMode::Test); $this->artisan('stripe:sync-catalogue')->assertSuccessful(); Settings::forget(StripeCatalogueMode::SETTING); $this->artisan('stripe:sync-catalogue')->assertSuccessful(); expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test); }); it('leaves no half-built catalogue that contradicts itself when a run dies', function () { OperatingMode::set(OperatingMode::Test); // Stripe geht mitten im Lauf weg, nachdem das erste Produkt schon angelegt // und seine ID gespeichert ist. `createProduct()` und `ensure()` werfen // ungefangen — stünde record() am ENDE von handle(), bliebe genau der // Zustand zurück, den der Test darüber beschreibt: IDs da, Herkunft leer. app()->instance(StripeClient::class, new class extends FakeStripeClient { public function createPrice( string $productId, int $amountCents, string $currency, string $interval, array $metadata = [], ?string $idempotencyKey = null, ): string { throw new RuntimeException('Stripe went away mid-run'); } }); try { app(Kernel::class)->call('stripe:sync-catalogue'); } catch (RuntimeException) { // Erwartet — der Abbruch IST der Testfall. } expect(StripeCatalogueMode::hasStoredObjects())->toBeTrue() ->and(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test); }); it('syncs into the new account once the stale ids are cleared', function () { OperatingMode::set(OperatingMode::Test); $this->artisan('stripe:sync-catalogue')->assertSuccessful(); OperatingMode::set(OperatingMode::Live); // Was der `breaks`-Satz und die Fehlermeldung dem Betreiber auftragen — // Zeiger UND Register, weil PlanPrices::inStep() für den Netto-Preis eines // Reverse-Charge-Kunden allein das Register fragt. DB::table('plan_families')->update(['stripe_product_id' => null]); DB::table('plan_prices')->update(['stripe_price_id' => null]); DB::table('stripe_plan_prices')->delete(); DB::table('stripe_addon_prices')->delete(); $this->artisan('stripe:sync-catalogue')->assertSuccessful(); expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Live) ->and(PlanPrice::query()->whereNotNull('stripe_price_id')->exists())->toBeTrue(); });