Make the plan-side adoption tests able to fail

Both new tests passed whether adoption ran or not: Stripe forgets an
idempotency key after twenty-four hours, and that expiry is the only
condition under which the 2026-07-29 duplicate was ever minted. With the
sync's own key still in the fake's ledger, createPrice() replayed the
right answer on its own, so testing the recognition step with the key
still in force tested nothing.

Clearing $this->stripe->keys before the second call is what lets
createPrice() actually mint, which is what gives both tests something to
fail on. The second test needed one more thing: this row's own domestic
Price is a legitimate orphan the moment its register row is gone, so it
had to be archived first — otherwise adoption would rightly reclaim it
before ever weighing the wrong-owner Price, and the refusal this test
exists to prove would never be reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 12:21:36 +02:00
parent 355f9cce01
commit cdda38c6b2
1 changed files with 49 additions and 1 deletions

View File

@ -318,6 +318,15 @@ it('takes over an orphaned package price', function () {
->value('stripe_price_id'); ->value('stripe_price_id');
StripePlanPrice::query()->where('stripe_price_id', $orphan)->delete(); StripePlanPrice::query()->where('stripe_price_id', $orphan)->delete();
// Load-bearing, not incidental cleanup: Stripe forgets an idempotency key
// after twenty-four hours, and that expiry is the only condition under
// which the 2026-07-29 duplicate was ever minted. With the sync's own key
// still in the fake's ledger, createPrice() would just replay $orphan's
// id on its own — proving nothing about adoption. Clearing it is what
// makes createPrice() able to mint a genuine second Price, which is the
// only way this test can fail.
$this->stripe->keys = [];
$before = count($this->stripe->prices); $before = count($this->stripe->prices);
$id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic()); $id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic());
@ -341,6 +350,18 @@ it('does not take a package price belonging to another catalogue row', function
$charged = PlanPrices::chargedCents($row, TaxTreatment::domestic()); $charged = PlanPrices::chargedCents($row, TaxTreatment::domestic());
$interval = $row->term === Subscription::TERM_YEARLY ? 'year' : 'month'; $interval = $row->term === Subscription::TERM_YEARLY ? 'year' : 'month';
// This row's OWN domestic Price from the sync above becomes a legitimate
// orphan the moment its register row is gone — exactly like the test
// above — and adopt() would be right to reclaim it. Archived here so the
// only Price left at this exact figure is the wrong-owner one below:
// otherwise adoption would correctly adopt the row's own orphan before
// ever weighing 'price_other_row', and the rejection this test exists to
// prove would never be reached.
$this->stripe->archivePrice((string) StripePlanPrice::query()
->where('plan_price_id', $row->id)
->where('reverse_charge', false)
->value('stripe_price_id'));
StripePlanPrice::query()->where('plan_price_id', $row->id)->delete(); StripePlanPrice::query()->where('plan_price_id', $row->id)->delete();
// Same product, same money, same interval — and plan_price_id says it is a // Same product, same money, same interval — and plan_price_id says it is a
@ -349,7 +370,34 @@ it('does not take a package price belonging to another catalogue row', function
$this->stripe->plantPrice('price_other_row', $product, $charged, (string) $row->currency, $this->stripe->plantPrice('price_other_row', $product, $charged, (string) $row->currency,
$interval, ['plan_price_id' => (string) ($row->id + 1000), 'tax_treatment' => 'domestic']); $interval, ['plan_price_id' => (string) ($row->id + 1000), 'tax_treatment' => 'domestic']);
// Same reason as the test above: with the sync's own idempotency key
// still in force, createPrice() would replay THIS row's own previous
// Price — which would also not be 'price_other_row', whether or not
// adoption ever looked at the wrong-owner Price at all. Clearing the
// ledger forces a real mint when nothing adoptable is found, which is
// what gives the assertions below something to catch.
$this->stripe->keys = [];
$before = count($this->stripe->prices);
$id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic()); $id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic());
expect($id)->not->toBe('price_other_row'); expect($id)->not->toBe('price_other_row')
// A fresh Price was minted rather than 'price_other_row' being handed
// out. Proves AdoptStripePrice's contradicts()/confirms() pair
// correctly refused the wrong-owner Price WHEN adoption runs — the
// two guard each other here exactly as they do on the module side, so
// either alone still catches this; only disabling both together lets
// 'price_other_row' through. It does NOT prove PlanPrices wires
// adopt() into ensure() at all: skipping that wiring entirely mints
// fresh here too, indistinguishable from a correct refusal. The test
// above ("takes over an orphaned package price") is what proves the
// wiring is present.
->and(count($this->stripe->prices))->toBe($before + 1)
// The Price belonging to the other row must survive untouched.
// AdoptStripePrice archives every orphan but the one it adopts, so a
// defect that let it treat 'price_other_row' as a duplicate of
// whatever it did adopt would take this down as collateral damage —
// exactly the failure a wrong plan_price_id must never cause.
->and($this->stripe->archived)->not->toContain('price_other_row');
}); });