CluPilotCloud/tests/Feature/Billing/SweepOrphanPricesTest.php

124 lines
5.2 KiB
PHP

<?php
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Contracts\Console\Kernel;
/**
* The orphans an operator can finally act on.
*
* The command lists EVERY active recurring Price at one of our Products that no
* row knows — adoptable or not; the only question it asks is whether a register
* row holds the id. That is wider than the log line AdoptStripePrice writes, and
* it is why stripe:sync-catalogue has to run first: see the command's own
* docblock, which had to be corrected away from the narrower claim.
*
* The fixtures below are all at the narrow end anyway — planted with no metadata
* at all, so nothing could have adopted them, which is what a person clicking
* through Stripe's dashboard leaves behind. That keeps each test about the
* sweep's own question (does a row know this id?) rather than about what the
* recognition step would have done with it.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
app(Kernel::class)->call('stripe:sync-catalogue');
$this->product = (string) StripeAddonPrice::query()
->where('addon_key', 'priority_support')
->value('stripe_product_id');
$this->known = (string) StripeAddonPrice::query()
->where('addon_key', 'priority_support')
->where('interval', 'month')
->where('reverse_charge', false)
->value('stripe_price_id');
$this->stripe->plantPrice('price_orphan', $this->product, 9900, 'EUR', 'month', []);
});
it('reports the orphan and leaves it alone', function () {
$this->artisan('stripe:sweep-orphan-prices')
->expectsOutputToContain('price_orphan')
->assertSuccessful();
expect($this->stripe->archived)->toBe([]);
});
it('never reports a price a row knows', function () {
$this->artisan('stripe:sweep-orphan-prices')
->doesntExpectOutputToContain($this->known)
->assertSuccessful();
});
it('sees the plan side too: a family product orphan is reported, a known plan price never', function () {
// Every other test in this file plants its orphan under the MODULE Product,
// so both halves of the plan side went untested: the PlanFamily half of
// products() and the StripePlanPrice half of the $known set. Blind to
// either, the command misses the LARGER half — a family Product collects a
// Price per version, term, treatment and rate change, a module Product four.
//
// Both assertions sit in one test on purpose: each fails for one of the two
// lines. Drop the PlanFamily pluck from products() and the family Product is
// never visited, so the orphan below goes unreported. Drop the
// StripePlanPrice pluck from $known and the row's own Price becomes an
// orphan the command names.
$row = PlanPrice::query()->firstOrFail();
$familyProduct = (string) $row->version->family->stripe_product_id;
$knownPlanPrice = (string) StripePlanPrice::query()
->where('plan_price_id', $row->id)
->where('reverse_charge', false)
->value('stripe_price_id');
// At the family's Product and at an amount no row of ours holds — what the
// sync leaves behind when it dies between Stripe's create and our insert,
// seen from the sweep's side.
$this->stripe->plantPrice('price_family_orphan', $familyProduct,
12300, (string) $row->currency, 'month', []);
$this->artisan('stripe:sweep-orphan-prices')
->expectsOutputToContain('price_family_orphan')
->doesntExpectOutputToContain($knownPlanPrice)
->assertSuccessful();
expect($familyProduct)->not->toBe('')
->and($knownPlanPrice)->not->toBe('')
// Nothing was touched: this is the reporting path, not --archive.
->and($this->stripe->archived)->toBe([]);
});
it('stops selling the orphan when asked to', function () {
$this->artisan('stripe:sweep-orphan-prices', ['--archive' => true])
->assertSuccessful();
// Archiving is harmless for the same reason it always is here: Stripe goes
// on billing every subscription already on an archived Price, it merely
// stops being sold.
expect($this->stripe->archived)->toBe(['price_orphan']);
});
it('touches nothing on a dry run, even when asked to archive', function () {
$this->artisan('stripe:sweep-orphan-prices', ['--archive' => true, '--dry-run' => true])
->expectsOutputToContain('price_orphan')
->assertSuccessful();
expect($this->stripe->archived)->toBe([]);
});
it('fails cleanly on a dry run when Stripe is not configured, rather than throwing', function () {
// Unlike stripe:sync-catalogue, this command has no local-only path: the
// report itself is a live activePricesFor() call, dry-run or not. A guard
// that only fired outside --dry-run would let this fall straight into
// HttpStripeClient's own "not configured" exception instead of the clean
// error below.
$this->stripe->configured = false;
$this->artisan('stripe:sweep-orphan-prices', ['--dry-run' => true])
->expectsOutputToContain('Stripe is not configured (STRIPE_SECRET is empty). Nothing was read.')
->assertFailed();
});