HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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();
    }
});