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/InvoiceServiceTest.php
<?php

/**
 * InvoiceService — read aggregator for the admin invoice screen.
 *
 * Covers:
 *  - dashboardStats() returns counts + paid revenue (joined through orders→order_items)
 *  - list() honours filter DTO (type, status, keyword, customer_uid, date range, sort)
 *  - sidebarStats() shape contract (5 widgets) without requiring a populated DB
 */

use App\Dto\Refactor\Invoice\InvoiceListFilterDto;
use App\Dto\Refactor\Invoice\InvoiceSidebarStatsDto;
use App\Library\OrderFulfillment\OrderItemTypes;
use App\Model\Invoice;
use App\Model\OrderItem;
use App\Services\Admin\InvoiceService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

uses(TestCase::class, DatabaseTransactions::class);

beforeEach(function () {
    $this->service = new InvoiceService();
});

/* ─────────── dashboardStats ─────────── */

it('dashboardStats returns the documented integer counts plus a numeric revenue', function () {
    $stats = $this->service->dashboardStats();

    expect($stats)
        ->toHaveKeys(['total', 'paid', 'unpaid', 'pending', 'revenue'])
        ->and($stats['total'])->toBeInt()
        ->and($stats['paid'])->toBeInt()
        ->and($stats['unpaid'])->toBeInt()
        ->and($stats['pending'])->toBeInt()
        ->and($stats['revenue'])->toBeFloat();
});

it('dashboardStats revenue sums order_items.amount across paid invoices only', function () {
    $baselinePaid = Invoice::paid()->count();
    $baselineRevenue = (float) $this->service->dashboardStats()['revenue'];

    $invoice = Invoice::factory()->paid()->create();
    OrderItem::factory()->create(['order_id' => $invoice->order_id, 'amount' => 100.00]);

    $after = $this->service->dashboardStats();

    expect($after['paid'])->toBe($baselinePaid + 1);
    // Two items now (29.99 from the factory + 100.00 we just added) = 129.99 added.
    expect($after['revenue'])->toEqualWithDelta($baselineRevenue + 129.99, 0.01);
});

/* ─────────── list() with DTO filters ─────────── */

it('list() filters by type code', function () {
    $sub = Invoice::factory()->create();
    OrderItem::factory()->create(['order_id' => $sub->order_id, 'type' => OrderItemTypes::SUBSCRIPTION_NEW]);

    $fee = Invoice::factory()->create();
    OrderItem::factory()->create(['order_id' => $fee->order_id, 'type' => OrderItemTypes::FEE_CUSTOM]);

    $dto = new InvoiceListFilterDto(type: OrderItemTypes::SUBSCRIPTION_NEW);
    $page = $this->service->list($dto);

    $ids = collect($page->items())->pluck('id')->all();
    expect($ids)->toContain($sub->id)->not->toContain($fee->id);
});

it('list() type filter matches both modern code and legacy FQCN aliases', function () {
    // Pre-refactor: row stored full FQCN class name on order_items.type.
    $legacy = Invoice::factory()->create();
    OrderItem::factory()->create([
        'order_id' => $legacy->order_id,
        'type'     => 'App\\Model\\OrderItemNewSubscription',  // legacy raw value
    ]);

    // Post-refactor: row stored modern short code.
    $modern = Invoice::factory()->create();
    OrderItem::factory()->create([
        'order_id' => $modern->order_id,
        'type'     => OrderItemTypes::SUBSCRIPTION_NEW,
    ]);

    // A different type — must NOT come back.
    $other = Invoice::factory()->create();
    OrderItem::factory()->create([
        'order_id' => $other->order_id,
        'type'     => OrderItemTypes::FEE_CUSTOM,
    ]);

    // Selecting the modern dropdown option should match BOTH formats.
    $dto = new InvoiceListFilterDto(type: OrderItemTypes::SUBSCRIPTION_NEW);
    $ids = collect($this->service->list($dto)->items())->pluck('id')->all();

    // Legacy FQCN row should still match the modern code via alias.
    expect($ids)->toContain($legacy->id);
    // Modern short-code row should match exactly.
    expect($ids)->toContain($modern->id);
    // Unrelated type must not leak in.
    expect($ids)->not->toContain($other->id);
});

it('list() respects status filter (paid)', function () {
    $paid = Invoice::factory()->paid()->create();
    $new  = Invoice::factory()->create();

    $dto = new InvoiceListFilterDto(status: 'paid');
    $ids = collect($this->service->list($dto)->items())->pluck('id')->all();

    expect($ids)->toContain($paid->id)->not->toContain($new->id);
});

it('list() applies keyword search across uid/number/billing_email', function () {
    $invoice = Invoice::factory()->create([
        'billing_email' => 'needle-' . uniqid() . '@example.com',
    ]);

    $dto = new InvoiceListFilterDto(keyword: 'needle-');
    $ids = collect($this->service->list($dto)->items())->pluck('id')->all();

    expect($ids)->toContain($invoice->id);
});

it('list() rejects an out-of-range per_page silently and falls back to 15', function () {
    $request = \Illuminate\Http\Request::create('/admin/invoices/listing', 'GET', ['per_page' => 9999]);
    $dto = InvoiceListFilterDto::fromRequest($request);

    expect($dto->perPage)->toBe(15);
});

it('list() defaults sort_direction to desc on bogus input', function () {
    $request = \Illuminate\Http\Request::create('/admin/invoices/listing', 'GET', [
        'sort_order' => 'invoices.created_at',
        'sort_direction' => 'sideways',
    ]);
    $dto = InvoiceListFilterDto::fromRequest($request);

    expect($dto->sortDirection)->toBe('desc');
});

/* ─────────── sidebarStats() ─────────── */

it('sidebarStats() returns the 5-widget DTO shape', function () {
    $bundle = $this->service->sidebarStats();

    expect($bundle)->toBeInstanceOf(InvoiceSidebarStatsDto::class);

    expect($bundle->pendingApproval)->toHaveKeys(['count', 'oldest_days', 'first_uid']);
    expect($bundle->failedRecent)->toBeArray();
    expect($bundle->topCustomers30d)->toBeArray();
    expect($bundle->recentActivity)->toBeArray();
    expect($bundle->revenueTrend)->toHaveKeys(['revenue30d', 'revenuePrev30d', 'deltaPct', 'currency', 'paid_count_30d']);
});

it('sidebarStats() pendingApproval counts current pending invoices', function () {
    // Use baseline diffs — DB may already have demo seed rows.
    $baseline = $this->service->sidebarStats()->pendingApproval['count'];

    $inv = Invoice::factory()->create();
    \App\Model\PaymentIntent::factory()->create([
        'invoice_id' => $inv->id,
        'status'     => \App\Model\PaymentIntent::STATUS_PENDING,
    ]);

    $bundle = $this->service->sidebarStats();
    expect($bundle->pendingApproval['count'])->toBeGreaterThanOrEqual($baseline + 1);
});