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/be.naniguide.com/tests/Feature/Model/InvoiceCreateNumberTest.php
<?php

/**
 * Invoice::createInvoiceNumber() — counter audit + format validation + notify.
 *
 * Exercises the full flow against the real DB: pre-seed `invoices` with
 * specific numeric values to control MAX(number), set Setting('invoice.current'),
 * invoke createInvoiceNumber(), then assert the saved number, the bumped
 * counter, and the notification row (or its absence).
 *
 * The "happy" tests place counter above the pre-existing MAX so no audit
 * fires; the "drift" tests seed a high-mark invoice and place counter below
 * or equal to it so the audit fires. Format-validation tests configure
 * intentionally bad sprintf templates and assert the explicit exception.
 *
 * A pure-unit subclass at the bottom proves the extracted seam
 * (`getMaxExistingInvoiceNumber()`) is overridable without touching the DB.
 */

use App\Model\AppNotification as AppNotificationModel;
use App\Model\Invoice;
use App\Model\Setting;
use App\Notifications\System\InvoiceCounterReset;
use App\Services\Notifications\Notifier;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

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

beforeEach(function () {
    Setting::set('invoice.format', '%08d');
    $this->baselineMax = (int) DB::table('invoices')
        ->selectRaw('COALESCE(MAX(CAST(`number` AS UNSIGNED)), 0) AS m')
        ->value('m');
});

/* ──────────────────────────────── counter audit ─────────────────────────── */

it('uses the counter and bumps it by 1 when counter > MAX(number)', function () {
    $start = $this->baselineMax + 1000;
    Setting::set('invoice.current', $start);
    $invoice = Invoice::factory()->create();

    $invoice->createInvoiceNumber();

    expect($invoice->number)->toBe(sprintf('%08d', $start));
    expect((int) Setting::get('invoice.current'))->toBe($start + 1);
    expect(AppNotificationModel::where('type', InvoiceCounterReset::class)
        ->where('group_key', 'invoice-counter-reset')
        ->whereBetween('created_at', [now()->subMinute(), now()])
        ->count()
    )->toBe(0);
});

it('snaps counter to MAX+1 and notifies admin when counter < MAX(number)', function () {
    $highMark = $this->baselineMax + 50_000;
    Invoice::factory()->create(['number' => sprintf('%08d', $highMark)]);

    $stuckAt = $highMark - 10;
    Setting::set('invoice.current', $stuckAt);

    $invoice = Invoice::factory()->create();
    $invoice->createInvoiceNumber();

    expect($invoice->number)->toBe(sprintf('%08d', $highMark + 1));
    expect((int) Setting::get('invoice.current'))->toBe($highMark + 2);

    $note = AppNotificationModel::where('type', InvoiceCounterReset::class)
        ->where('audience', 'admins')
        ->where('group_key', 'invoice-counter-reset')
        ->latest('updated_at')->first();

    expect($note)->not->toBeNull()
        ->and($note->data['body'])->toContain((string) $stuckAt)
        ->and($note->data['body'])->toContain((string) ($highMark + 1))
        ->and($note->data['body'])->toContain((string) $highMark)
        ->and($note->data['meta']['from'])->toBe($stuckAt)
        ->and($note->data['meta']['to'])->toBe($highMark + 1)
        ->and($note->data['meta']['max_existing'])->toBe($highMark);
});

it('snaps counter when counter == MAX(number) (the off-by-one edge would collide)', function () {
    $highMark = $this->baselineMax + 60_000;
    Invoice::factory()->create(['number' => sprintf('%08d', $highMark)]);

    Setting::set('invoice.current', $highMark);

    $invoice = Invoice::factory()->create();
    $invoice->createInvoiceNumber();

    expect($invoice->number)->toBe(sprintf('%08d', $highMark + 1));
    expect((int) Setting::get('invoice.current'))->toBe($highMark + 2);
    expect(AppNotificationModel::where('type', InvoiceCounterReset::class)
        ->where('group_key', 'invoice-counter-reset')
        ->latest('updated_at')->first()
    )->not->toBeNull();
});

it('dedupes repeated resets onto a single notification row via groupKey', function () {
    $highMark = $this->baselineMax + 70_000;
    Invoice::factory()->create(['number' => sprintf('%08d', $highMark)]);

    // First reset: counter way below high-mark.
    Setting::set('invoice.current', $highMark - 100);
    Invoice::factory()->create()->createInvoiceNumber();

    // Second reset: artificially knock counter back down again.
    $secondStuckAt = $highMark - 50;
    Setting::set('invoice.current', $secondStuckAt);
    Invoice::factory()->create()->createInvoiceNumber();

    $rows = AppNotificationModel::where('type', InvoiceCounterReset::class)
        ->where('group_key', 'invoice-counter-reset')
        ->where('audience', 'admins')
        ->whereBetween('updated_at', [now()->subMinute(), now()])
        ->get();

    // groupKey collapses repeated occurrences — exactly 1 row, latest data wins.
    expect($rows)->toHaveCount(1)
        ->and($rows[0]->data['meta']['from'])->toBe($secondStuckAt);
});

/* ───────────────────────────── format validation ───────────────────────── */

it('throws RuntimeException when format produces a non-numeric value (INV prefix)', function () {
    Setting::set('invoice.format', 'INV%04d');
    Setting::set('invoice.current', $this->baselineMax + 1);
    $invoice = Invoice::factory()->create();

    expect(fn() => $invoice->createInvoiceNumber())
        ->toThrow(\RuntimeException::class, 'Invalid invoice number format "INV%04d"');
});

it('throws when format produces a decimal output', function () {
    Setting::set('invoice.format', '%9.5f');
    Setting::set('invoice.current', $this->baselineMax + 1);
    $invoice = Invoice::factory()->create();

    expect(fn() => $invoice->createInvoiceNumber())
        ->toThrow(\RuntimeException::class, 'Must produce a numeric value');
});

it('throws when format produces a signed (+) output', function () {
    Setting::set('invoice.format', '%+d');
    Setting::set('invoice.current', $this->baselineMax + 1);
    $invoice = Invoice::factory()->create();

    expect(fn() => $invoice->createInvoiceNumber())
        ->toThrow(\RuntimeException::class);
});

it('throws when counter is negative and format renders the sign', function () {
    Setting::set('invoice.format', '%d');
    Setting::set('invoice.current', -1023432);
    $invoice = Invoice::factory()->create();

    expect(fn() => $invoice->createInvoiceNumber())
        ->toThrow(\RuntimeException::class);
});

/* ───────────────────────── format validation — accepted ────────────────── */

it('accepts %08d (default — zero-padded fixed width)', function () {
    Setting::set('invoice.format', '%08d');
    Setting::set('invoice.current', $this->baselineMax + 1);
    $invoice = Invoice::factory()->create();

    $invoice->createInvoiceNumber();
    expect($invoice->number)->toMatch('/^\d+$/');
});

it('accepts %d (no padding)', function () {
    Setting::set('invoice.format', '%d');
    Setting::set('invoice.current', $this->baselineMax + 1);
    $invoice = Invoice::factory()->create();

    $invoice->createInvoiceNumber();
    expect($invoice->number)->toMatch('/^\d+$/');
});

it('accepts %06d for small counters (e.g. 000001)', function () {
    // Force the counter low enough that '%06d' yields the documented '000001'
    // shape; the audit will snap it past baselineMax. The acceptance contract
    // here is that the resulting number is still pure digits.
    Setting::set('invoice.format', '%06d');
    Setting::set('invoice.current', 1);
    $invoice = Invoice::factory()->create();

    $invoice->createInvoiceNumber();
    expect($invoice->number)->toMatch('/^\d+$/');
});

/* ──────────────── pure unit demo: extracted seam is mockable ─────────── */

it('honours the extracted getMaxExistingInvoiceNumber() seam — no DB needed for MAX', function () {
    // Subclass overrides the DB-touching method so this test exercises ONLY
    // the audit + sprintf + dispatch logic. Setting + Notifier are still real
    // (they need a container) but MAX is stubbed.
    Setting::set('invoice.current', 100);

    $stub = new class extends Invoice {
        public int $stubMax = 500;
        public bool $saveCalled = false;
        protected function getMaxExistingInvoiceNumber(): int { return $this->stubMax; }
        public function save(array $options = []): bool { $this->saveCalled = true; return true; }
    };

    $stub->createInvoiceNumber();

    expect($stub->saveCalled)->toBeTrue();
    expect($stub->number)->toBe(sprintf('%08d', 501));
    expect((int) Setting::get('invoice.current'))->toBe(502);

    $note = AppNotificationModel::where('type', InvoiceCounterReset::class)
        ->where('group_key', 'invoice-counter-reset')
        ->latest('updated_at')->first();
    expect($note)->not->toBeNull()
        ->and($note->data['meta']['from'])->toBe(100)
        ->and($note->data['meta']['to'])->toBe(501)
        ->and($note->data['meta']['max_existing'])->toBe(500);
});

it('does not dispatch notification when stubbed max is below counter', function () {
    Setting::set('invoice.current', 1000);

    $stub = new class extends Invoice {
        protected function getMaxExistingInvoiceNumber(): int { return 500; }
        public function save(array $options = []): bool { return true; }
    };

    $stub->createInvoiceNumber();

    expect($stub->number)->toBe(sprintf('%08d', 1000));
    expect((int) Setting::get('invoice.current'))->toBe(1001);
    expect(AppNotificationModel::where('type', InvoiceCounterReset::class)
        ->whereBetween('updated_at', [now()->subMinute(), now()])
        ->count()
    )->toBe(0);

    // Defense against the Notifier being silently unbound: assert it resolves.
    expect(app(Notifier::class))->toBeInstanceOf(Notifier::class);
});