CluPilotCloud/tests/Feature/Billing/StripeIdempotencyKeyTest.php

123 lines
4.8 KiB
PHP

<?php
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\HttpStripeClient;
use Illuminate\Support\Facades\Http;
/**
* A key says "I have already sent this call" — not "this is what the object is".
*
* The difference cost a day. A run created a Price at Stripe and died before the
* row was written; the next sweep sent the SAME key with an added metadata field
* and Stripe refused it for twenty-four hours — the sweep and, because
* AddonPrices::ensure() also runs inside a customer's module booking, the
* booking with it.
*
* So every parameter that goes on the wire goes into the key. On the catalogue
* calls only: for a refund or a second subscription item, Stripe's refusal is
* the thing standing between a customer and being charged twice.
*/
beforeEach(function () {
// The environment fallback is enough — HttpStripeClient reads the vault,
// and the vault falls back to config until a secret has been stored.
config()->set('services.stripe.secret', 'sk_test_plan_task_one');
});
it('sends a different key once the metadata changes', function () {
Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_x'])]);
$client = new HttpStripeClient;
$spoken = 'clupilot-addon-price-priority_support-month-3480-EUR';
// The call as it stood before 9da1358, and the call after it: same money,
// same interval, one metadata field more.
$client->createPrice('prod_1', 3480, 'EUR', 'month',
['addon' => 'priority_support'], $spoken);
$client->createPrice('prod_1', 3480, 'EUR', 'month',
['addon' => 'priority_support', 'tax_treatment' => 'domestic'], $spoken);
$sent = collect(Http::recorded())
->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null)
->all();
expect($sent[0])->toStartWith($spoken)
->and($sent[1])->toStartWith($spoken)
->and($sent[1])->not->toBe($sent[0]);
});
it('sends the same key for the very same call', function () {
Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_x'])]);
$client = new HttpStripeClient;
foreach ([1, 2] as $ignored) {
$client->createPrice('prod_1', 3480, 'EUR', 'month',
['addon' => 'priority_support'], 'clupilot-addon-price');
}
$sent = collect(Http::recorded())
->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null)
->unique()
->all();
expect($sent)->toHaveCount(1);
});
it('fingerprints the product call too, where the same trap was waiting', function () {
Http::fake(['api.stripe.com/*' => Http::response(['id' => 'prod_x'])]);
$client = new HttpStripeClient;
$client->createProduct('Priority Support', ['addon' => 'priority_support'], 'clupilot-addon-product-x');
$client->createProduct('Priority Support', ['addon' => 'priority_support', 'sold_as' => 'entitlement'], 'clupilot-addon-product-x');
$sent = collect(Http::recorded())
->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null)
->all();
expect($sent[1])->not->toBe($sent[0]);
});
it('leaves the money calls their bare key, so Stripe still refuses a changed one', function () {
Http::fake(['api.stripe.com/*' => Http::response(['id' => 'x'])]);
$client = new HttpStripeClient;
$client->refund('pi_1', 500, 'clupilot-refund-7');
$client->cancelSubscription('sub_1', 'at_period_end', 'clupilot-cancel-7');
$client->addSubscriptionItem('sub_1', 'price_1', 1, 'none', 'clupilot-item-7');
$sent = collect(Http::recorded())
->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null)
->all();
expect($sent)->toBe(['clupilot-refund-7', 'clupilot-cancel-7', 'clupilot-item-7']);
});
it('reproduces the refusal Stripe makes, which the fake used to swallow', function () {
$fake = new FakeStripeClient;
$fake->refund('pi_1', 500, 'clupilot-refund-7');
// Same key, different amount. Stripe answers 400; the fake said nothing and
// replayed the first refund's id, which is how a test could pass over the
// very failure that stopped production.
expect(fn () => $fake->refund('pi_1', 900, 'clupilot-refund-7'))
->toThrow(RuntimeException::class, 'same parameters');
});
it('mints a second price rather than blocking when the metadata moved', function () {
$fake = new FakeStripeClient;
$first = $fake->createPrice('prod_1', 3480, 'EUR', 'month',
['addon' => 'priority_support'], 'clupilot-addon-price');
$second = $fake->createPrice('prod_1', 3480, 'EUR', 'month',
['addon' => 'priority_support', 'tax_treatment' => 'domestic'], 'clupilot-addon-price');
// Two objects, no exception. That the second one is not WANTED is the job of
// AdoptStripePrice, not of the key — see StripePriceAdoptionTest.
expect($second)->not->toBe($first);
});