File: /home/xedaptot/ai.naniguide.com/tests/Feature/Admin/SubscriptionServiceTest.php
<?php
/**
* SubscriptionService — read aggregator for the admin subscription screen.
*
* Covers:
* - lifecycleCounts() — single grouped query, cancelled+terminated merge.
* - attention() — three click-to-apply chip surfaces.
* - list() — lifecycle / attention / recurring / reconcile filter combinations.
* - SubscriptionListFilterDto — input sanitisation (lesson L6: separate expects, no message arg).
*
* Uses baseline diffs (lesson L7 spirit) — DB may already have demo seed rows,
* so we never assert absolute counts; only the deltas this test produced.
*/
use App\Dto\Refactor\Subscription\SubscriptionListFilterDto;
use App\Model\Admin;
use App\Model\Customer;
use App\Model\Plan;
use App\Model\Subscription;
use App\Services\Admin\SubscriptionService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
uses(TestCase::class, DatabaseTransactions::class);
beforeEach(function () {
$this->service = new SubscriptionService();
$this->admin = Admin::first() ?? Admin::factory()->create();
// Build a fresh customer + plan once per test so seeded data doesn't bleed.
$this->customer = Customer::factory()->create(['admin_id' => $this->admin->id]);
$this->plan = Plan::factory()->create(['status' => 'active']);
});
function makeSub(array $overrides = []): Subscription
{
/** @var \Tests\TestCase $t */
$t = test();
// Fresh customer per sub — Subscription model invariant blocks multiple
// active/new subs on the same customer (HasUid::saving check). Tests
// exercising lifecycle counts need many subs; new customer keeps each
// save independent.
$customer = isset($overrides['customer_id'])
? Customer::find($overrides['customer_id'])
: Customer::factory()->create(['admin_id' => $t->admin->id]);
return Subscription::forceCreate(array_merge([
'uid' => uniqid('sub_'),
'customer_id' => $customer->id,
'plan_id' => $t->plan->id,
'status' => Subscription::STATUS_ACTIVE,
'is_recurring' => true,
], $overrides));
}
/* ─────────── lifecycleCounts() ─────────── */
it('lifecycleCounts returns the documented int-keyed bucket shape', function () {
$counts = $this->service->lifecycleCounts($this->admin);
expect($counts)->toHaveKeys(['all', 'active', 'pending', 'cancelled', 'ended']);
expect($counts['all'])->toBeInt();
expect($counts['active'])->toBeInt();
expect($counts['pending'])->toBeInt();
expect($counts['cancelled'])->toBeInt();
expect($counts['ended'])->toBeInt();
});
it('lifecycleCounts merges cancelled + terminated into the "cancelled" bucket', function () {
$baseline = $this->service->lifecycleCounts($this->admin);
makeSub(['status' => Subscription::STATUS_CANCELLED]);
makeSub(['status' => Subscription::STATUS_TERMINATED]);
makeSub(['status' => Subscription::STATUS_TERMINATED]);
$after = $this->service->lifecycleCounts($this->admin);
// Both statuses fold into the "cancelled" tab — admin UX merge.
expect($after['cancelled'])->toBe($baseline['cancelled'] + 3);
// The other buckets are unaffected by terminated rows.
expect($after['active'])->toBe($baseline['active']);
expect($after['pending'])->toBe($baseline['pending']);
expect($after['ended'])->toBe($baseline['ended']);
// Total grows by exactly 3.
expect($after['all'])->toBe($baseline['all'] + 3);
});
it('lifecycleCounts maps STATUS_NEW to the "pending" bucket', function () {
$baseline = $this->service->lifecycleCounts($this->admin);
makeSub(['status' => Subscription::STATUS_NEW]);
$after = $this->service->lifecycleCounts($this->admin);
expect($after['pending'])->toBe($baseline['pending'] + 1);
});
/* ─────────── attention() ─────────── */
it('attention returns the documented 3-bucket DTO', function () {
$attn = $this->service->attention($this->admin);
expect($attn->pendingApproval)->toHaveKeys(['count', 'first_uid']);
expect($attn->endingSoon)->toHaveKeys(['count', 'first_uid']);
expect($attn->reconcileIssue)->toHaveKeys(['count', 'first_uid']);
expect($attn->hasAny())->toBeBool();
});
it('attention.endingSoon counts active subs ending within 7 days', function () {
$baseline = $this->service->attention($this->admin)->endingSoon['count'];
makeSub([
'status' => Subscription::STATUS_ACTIVE,
'current_period_ends_at' => now()->addDays(3),
]);
// A sub ending 30 days out should NOT be counted.
makeSub([
'status' => Subscription::STATUS_ACTIVE,
'current_period_ends_at' => now()->addDays(30),
]);
$after = $this->service->attention($this->admin)->endingSoon['count'];
expect($after)->toBe($baseline + 1);
});
it('attention.reconcileIssue counts subs with a non-null remote_reconcile_status', function () {
$baseline = $this->service->attention($this->admin)->reconcileIssue['count'];
makeSub([
'status' => Subscription::STATUS_ACTIVE,
'remote_reconcile_status' => Subscription::REMOTE_RECONCILE_STATUS_SYNC_FAILED,
]);
$after = $this->service->attention($this->admin)->reconcileIssue['count'];
expect($after)->toBe($baseline + 1);
});
/* ─────────── list() filter combinations ─────────── */
it('list lifecycle=pending returns only STATUS_NEW rows', function () {
$newSub = makeSub(['status' => Subscription::STATUS_NEW]);
$activeSub = makeSub(['status' => Subscription::STATUS_ACTIVE]);
$dto = new SubscriptionListFilterDto(lifecycle: 'pending', perPage: 100);
$ids = collect($this->service->list($dto, $this->admin)->items())->pluck('id')->all();
expect($ids)->toContain($newSub->id);
expect($ids)->not->toContain($activeSub->id);
});
it('list lifecycle=cancelled merges CANCELLED + TERMINATED rows', function () {
$cancelled = makeSub(['status' => Subscription::STATUS_CANCELLED]);
$terminated = makeSub(['status' => Subscription::STATUS_TERMINATED]);
$active = makeSub(['status' => Subscription::STATUS_ACTIVE]);
$dto = new SubscriptionListFilterDto(lifecycle: 'cancelled', perPage: 100);
$ids = collect($this->service->list($dto, $this->admin)->items())->pluck('id')->all();
// Both flavours of "stopped" merge into the same admin tab.
expect($ids)->toContain($cancelled->id);
expect($ids)->toContain($terminated->id);
expect($ids)->not->toContain($active->id);
});
it('list recurring=off returns only is_recurring=false rows', function () {
$oneTime = makeSub(['is_recurring' => false]);
$auto = makeSub(['is_recurring' => true]);
$dto = new SubscriptionListFilterDto(recurring: 'off', perPage: 100);
$ids = collect($this->service->list($dto, $this->admin)->items())->pluck('id')->all();
expect($ids)->toContain($oneTime->id);
expect($ids)->not->toContain($auto->id);
});
it('list reconcile=has_issue returns only rows with a non-null reconcile status', function () {
$bad = makeSub(['remote_reconcile_status' => Subscription::REMOTE_RECONCILE_STATUS_SYNC_FAILED]);
$ok = makeSub(['remote_reconcile_status' => null]);
$dto = new SubscriptionListFilterDto(reconcile: 'has_issue', perPage: 100);
$ids = collect($this->service->list($dto, $this->admin)->items())->pluck('id')->all();
expect($ids)->toContain($bad->id);
expect($ids)->not->toContain($ok->id);
});
it('list attention=ending_soon narrows further within active rows', function () {
$imminent = makeSub([
'status' => Subscription::STATUS_ACTIVE,
'current_period_ends_at' => now()->addDays(3),
]);
$faraway = makeSub([
'status' => Subscription::STATUS_ACTIVE,
'current_period_ends_at' => now()->addDays(60),
]);
$newSub = makeSub(['status' => Subscription::STATUS_NEW]);
$dto = new SubscriptionListFilterDto(lifecycle: 'all', attention: 'ending_soon', perPage: 100);
$ids = collect($this->service->list($dto, $this->admin)->items())->pluck('id')->all();
expect($ids)->toContain($imminent->id);
expect($ids)->not->toContain($faraway->id);
expect($ids)->not->toContain($newSub->id);
});
/* ─────────── SubscriptionListFilterDto::fromRequest sanitisation ─────────── */
it('fromRequest defaults invalid lifecycle to "all"', function () {
$req = \Illuminate\Http\Request::create('/x', 'GET', ['lifecycle' => 'badvalue']);
$dto = SubscriptionListFilterDto::fromRequest($req);
expect($dto->lifecycle)->toBe('all');
});
it('fromRequest defaults invalid recurring to "all"', function () {
$req = \Illuminate\Http\Request::create('/x', 'GET', ['recurring' => 'maybe']);
$dto = SubscriptionListFilterDto::fromRequest($req);
expect($dto->recurring)->toBe('all');
});
it('fromRequest defaults invalid sort_by to created_at', function () {
$req = \Illuminate\Http\Request::create('/x', 'GET', ['sort_by' => 'subscriptions.evil_field']);
$dto = SubscriptionListFilterDto::fromRequest($req);
expect($dto->sortBy)->toBe('subscriptions.created_at');
});
it('fromRequest clamps out-of-range per_page to 15', function () {
$req = \Illuminate\Http\Request::create('/x', 'GET', ['per_page' => 9999]);
$dto = SubscriptionListFilterDto::fromRequest($req);
expect($dto->perPage)->toBe(15);
});
it('fromRequest accepts a valid attention chip and rejects an unknown one', function () {
$valid = SubscriptionListFilterDto::fromRequest(
\Illuminate\Http\Request::create('/x', 'GET', ['attention' => 'pending_approval'])
);
$bad = SubscriptionListFilterDto::fromRequest(
\Illuminate\Http\Request::create('/x', 'GET', ['attention' => 'attack-vector'])
);
expect($valid->attention)->toBe('pending_approval');
expect($bad->attention)->toBeNull();
});
/* ─────────── dashboardStats() — KPI hero shape ─────────── */
it('dashboardStats returns the documented hero shape', function () {
$stats = $this->service->dashboardStats($this->admin);
expect($stats)->toHaveKeys(['total', 'active', 'recurring', 'ending_soon_7d', 'mrr', 'currency', 'pending_approval']);
expect($stats['total'])->toBeInt();
expect($stats['active'])->toBeInt();
expect($stats['recurring'])->toBeInt();
expect($stats['ending_soon_7d'])->toBeInt();
expect($stats['mrr'])->toBeFloat();
expect($stats['currency'])->toBeString();
expect($stats['pending_approval'])->toBeInt();
});
it('dashboardStats MRR sums monthly-normalised plan price across active+recurring subs', function () {
$baseline = $this->service->dashboardStats($this->admin)['mrr'];
// A $30/month plan adds exactly $30 to MRR.
$monthly = Plan::factory()->create(['price' => 30.00, 'frequency_amount' => 1, 'frequency_unit' => 'month']);
Subscription::forceCreate([
'uid' => uniqid('sub_'),
'customer_id' => Customer::factory()->create(['admin_id' => $this->admin->id])->id,
'plan_id' => $monthly->id,
'status' => Subscription::STATUS_ACTIVE,
'is_recurring' => true,
]);
// A $360/year plan normalises to $30/month — same delta.
$yearly = Plan::factory()->create(['price' => 360.00, 'frequency_amount' => 1, 'frequency_unit' => 'year']);
Subscription::forceCreate([
'uid' => uniqid('sub_'),
'customer_id' => Customer::factory()->create(['admin_id' => $this->admin->id])->id,
'plan_id' => $yearly->id,
'status' => Subscription::STATUS_ACTIVE,
'is_recurring' => true,
]);
$after = $this->service->dashboardStats($this->admin)['mrr'];
expect($after - $baseline)->toEqualWithDelta(60.0, 0.05);
});
it('dashboardStats MRR ignores non-recurring and non-active subs', function () {
$baseline = $this->service->dashboardStats($this->admin)['mrr'];
$plan = Plan::factory()->create(['price' => 100.00, 'frequency_amount' => 1, 'frequency_unit' => 'month']);
// One-time (is_recurring=false) — must not contribute.
Subscription::forceCreate([
'uid' => uniqid('sub_'),
'customer_id' => Customer::factory()->create(['admin_id' => $this->admin->id])->id,
'plan_id' => $plan->id,
'status' => Subscription::STATUS_ACTIVE,
'is_recurring' => false,
]);
// Cancelled — must not contribute.
Subscription::forceCreate([
'uid' => uniqid('sub_'),
'customer_id' => Customer::factory()->create(['admin_id' => $this->admin->id])->id,
'plan_id' => $plan->id,
'status' => Subscription::STATUS_CANCELLED,
'is_recurring' => true,
]);
$after = $this->service->dashboardStats($this->admin)['mrr'];
expect($after)->toEqualWithDelta($baseline, 0.05);
});
/* ─────────── planMix() ─────────── */
it('planMix returns top plans with share_pct summing toward 100', function () {
$mix = $this->service->planMix($this->admin, 5);
if (count($mix) === 0) {
expect(true)->toBeTrue(); // nothing to assert against
return;
}
foreach ($mix as $row) {
expect($row)->toHaveKeys(['plan_uid', 'plan_name', 'sub_count', 'share_pct']);
expect($row['sub_count'])->toBeInt();
expect($row['share_pct'])->toBeFloat();
}
// Top entry's count must be ≥ subsequent entries (sorted desc).
for ($i = 1; $i < count($mix); $i++) {
expect($mix[$i - 1]['sub_count'])->toBeGreaterThanOrEqual($mix[$i]['sub_count']);
}
});
/* ─────────── reconcileHealth() ─────────── */
it('reconcileHealth returns 0/0 when no remote subscriptions exist', function () {
// Strip any remote-flagged seed rows so we get a clean 0-state for this assert.
\Illuminate\Support\Facades\DB::table('subscriptions')->update(['remote_subscription_id' => null]);
$health = $this->service->reconcileHealth($this->admin);
expect($health)->toEqual([
'synced' => 0,
'issue' => 0,
'total' => 0,
'pct_synced' => null,
'first_issue_uid' => null,
]);
});
it('reconcileHealth counts issues vs synced when remotes exist', function () {
\Illuminate\Support\Facades\DB::table('subscriptions')->update(['remote_subscription_id' => null]);
makeSub([
'remote_subscription_id' => 'sub_remote_clean_' . uniqid(),
'remote_reconcile_status' => null,
]);
makeSub([
'remote_subscription_id' => 'sub_remote_drifted_' . uniqid(),
'remote_reconcile_status' => Subscription::REMOTE_RECONCILE_STATUS_SYNC_FAILED,
]);
$health = $this->service->reconcileHealth($this->admin);
expect($health['total'])->toBe(2);
expect($health['synced'])->toBe(1);
expect($health['issue'])->toBe(1);
expect($health['pct_synced'])->toBe(50.0);
expect($health['first_issue_uid'])->not->toBeNull();
});
/* ─────────── mrrTrend() — daily series & delta ─────────── */
it('mrrTrend returns 30 daily entries and a non-null new_count_30d int', function () {
$trend = $this->service->mrrTrend($this->admin);
expect($trend)->toHaveKeys(['mrr_now', 'delta_pct', 'daily', 'new_count_30d']);
expect($trend['daily'])->toBeArray();
expect(count($trend['daily']))->toBe(30);
expect($trend['new_count_30d'])->toBeInt();
// Each daily entry has the documented shape.
foreach ($trend['daily'] as $row) {
expect($row)->toHaveKeys(['date', 'count']);
expect($row['count'])->toBeInt();
}
});