File: /home/xedaptot/ai.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);
});