laravel-shop/tests/Feature/Loan/LoanShopCommandsTest.php

346 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Blax\Shop\Tests\Feature\Loan;
use Blax\Shop\Console\Commands\ShopAvailabilityCommand;
use Blax\Shop\Console\Commands\ShopStocksCommand;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Inspector commands ({@see ShopStocksCommand}, {@see ShopAvailabilityCommand})
* read from product_stocks / product_purchases. CommandStocksTest and
* CommandAvailabilityTest already exercise them against SIMPLE products; this
* file covers the LOANABLE path — borrow + return cycles. A regression in
* loan↔stock wiring shows up here as wrong Assigned / Used / Available numbers
* earlier than any HTTP test would catch it.
*
* The Assigned-inflation bug ({@see self::assigned_for_loanable_product_must_not_inflate_after_a_return_cycle})
* is enforced here: getMaxStocksAttribute() sums every INCREASE entry — and
* the host-driven return path fires an INCREASE — so a 3-copy book that's
* been borrowed-and-returned once used to render as Assigned=4. The command
* now consults the loan-aware `total_quantity` accessor instead.
*/
class LoanShopCommandsTest extends TestCase
{
use RefreshDatabase;
private User $borrower;
protected function setUp(): void
{
parent::setUp();
$this->borrower = User::factory()->create();
}
private function newBook(string $name, string $sku): CmdLoanBook
{
return CmdLoanBook::create(['name' => $name, 'sku' => $sku]);
}
private function runOk(string $command, array $params = []): string
{
$exit = Artisan::call($command, $params);
$output = Artisan::output();
$this->assertSame(0, $exit, "{$command} returned non-zero:\n{$output}");
return $output;
}
/* ─────────────────────── shop:stocks (overview) ─────────────────────── */
#[Test]
public function shop_stocks_overview_lists_a_loaned_book_with_correct_counts(): void
{
$book = $this->newBook('Dune', 'CMD-DUNE');
$book->increaseStock(3);
$book->checkOutTo($this->borrower);
$output = $this->runOk(ShopStocksCommand::class);
$this->assertStringContainsString('Dune', $output);
$this->assertSame(1, $this->loanedDecreases($book), 'one DECREASE row from the loan');
$this->assertSame(2, $book->fresh()->getAvailableStock(), '3 copies 1 loan = 2 available');
}
#[Test]
public function shop_stocks_overview_for_a_fully_loaned_book_reports_zero_available(): void
{
$book = $this->newBook('Ember', 'CMD-EMBER');
$book->increaseStock(1);
$book->checkOutTo($this->borrower);
$output = $this->runOk(ShopStocksCommand::class);
$this->assertStringContainsString('Ember', $output);
$this->assertSame(0, $book->fresh()->getAvailableStock(), 'last copy is out');
$this->assertSame(1, $this->loanedDecreases($book->fresh()));
}
/* ─────────────────────── shop:stocks (detail) ─────────────────────── */
#[Test]
public function shop_stocks_detail_view_renders_the_full_ledger_for_a_loaned_book(): void
{
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
$book = $this->newBook('Hyperion', 'CMD-HYP');
$book->increaseStock(3);
// Borrow twice, return one. With the claim-based loan model the
// ledger carries: seed INCREASE, two claim cycles each writing a
// DECREASE + PHYSICALLY_CLAIMED row, plus one RETURN from the
// released claim — six rows total, but the operator only needs to
// see the standard increase/decrease verbs that the command renders.
$loan = $book->checkOutTo($this->borrower);
$book->checkOutTo(User::factory()->create());
$loan->markReturned();
$output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP']);
$this->assertStringContainsString('Hyperion', $output);
$this->assertStringContainsString('ASSIGNED', $output);
$this->assertStringContainsString('AVAILABLE', $output);
$this->assertStringContainsString('Recent stock ledger', $output);
$this->assertStringContainsString('increase', $output);
$this->assertStringContainsString('decrease', $output);
// 3 copies, 1 still out → 2 available. Verified against the model to
// sidestep ASCII-column regex fragility.
$this->assertSame(2, $book->fresh()->getAvailableStock());
}
#[Test]
public function assigned_for_loanable_product_must_not_inflate_after_a_return_cycle(): void
{
// Regression test for the bug where shop:stocks rendered Assigned=4
// for a 3-copy book that had been borrowed-and-returned once. With
// the new claim-based loan model the issue disappears at the source:
// checkout writes a DECREASE + PHYSICALLY_CLAIMED pair, the return
// releases the claim (status flip + RETURN row), and the net effect
// on every accessor reads exactly 3 copies — no inflation possible.
$book = $this->newBook('Hyperion', 'CMD-HYP-RC');
$book->increaseStock(3);
$loan = $book->checkOutTo($this->borrower);
$loan->markReturned();
$fresh = $book->fresh();
$this->assertSame(3, (int) $fresh->total_quantity, 'loan-aware accessor reads true count');
$this->assertSame(3, $fresh->getPhysicalStock(), 'physical reads true count');
// Detail view: ASSIGNED row must be 3, not 4.
$output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP-RC']);
// The detail view renders labels on one row and values on the next.
// Split into lines and look at the value row that follows the ASSIGNED
// label row — the first numeric token in that line is Assigned.
$assigned = $this->detailViewAssignedValue($output);
$this->assertSame(
3,
$assigned,
"shop:stocks detail must render Assigned=3 (physical capacity), got {$assigned}",
);
// Overview must also be consistent.
$overviewOutput = $this->runOk(ShopStocksCommand::class);
$row = $this->overviewRow($overviewOutput, 'Hyperion');
$this->assertNotNull($row, 'Hyperion row must appear in the overview table');
$this->assertSame(
3,
$row['assigned'],
'shop:stocks overview must report Assigned=3 for a borrowed-then-returned 3-copy book',
);
}
/* ─────────────────── shop:stocks:availability (calendar) ─────────────── */
#[Test]
public function shop_stocks_availability_headline_surfaces_physical_count(): void
{
// Coverage for the new "Physical N" headline that complements
// "Available N". For a loanable product the gap between the two is the
// loan tally — physical stays at the catalogue size while available
// drops as copies go out.
$book = $this->newBook('Hyperion', 'CMD-HYP-PHYS');
$book->increaseStock(3);
$book->checkOutTo($this->borrower);
$output = $this->runOk(ShopAvailabilityCommand::class, ['product' => 'CMD-HYP-PHYS']);
$this->assertStringContainsString('Physical 3', $output, 'physical = 2 on shelf + 1 active loan = 3');
$this->assertStringContainsString('Available 2', $output);
}
#[Test]
public function shop_stocks_detail_view_carries_a_physical_box(): void
{
// Operator-side: shop:stocks {product} renders the totals box; the
// PHYSICAL cell should sit next to ASSIGNED and reflect the loan-aware
// count.
$book = $this->newBook('Atlas', 'CMD-ATLAS-PHYS');
$book->increaseStock(4);
$book->checkOutTo($this->borrower);
$output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-ATLAS-PHYS']);
$this->assertStringContainsString('PHYSICAL', $output);
$this->assertSame(4, $book->fresh()->getPhysicalStock(), 'model agrees with command');
}
#[Test]
public function shop_stocks_availability_headline_reads_zero_for_a_fully_loaned_book(): void
{
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
$book = $this->newBook('Solitaire', 'CMD-SOL');
$book->increaseStock(1);
$book->checkOutTo($this->borrower);
$output = $this->runOk(ShopAvailabilityCommand::class, [
'product' => 'CMD-SOL',
'--from' => '2026-05-01',
'--to' => '2026-05-31',
]);
$this->assertStringContainsString('Solitaire', $output);
$this->assertStringContainsString('May 2026', $output);
$this->assertStringContainsString('Available 0', $output);
$this->assertStringContainsString('MON', $output);
$this->assertStringContainsString('SUN', $output);
}
#[Test]
public function shop_stocks_availability_headline_recovers_after_a_return(): void
{
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
$book = $this->newBook('Singular', 'CMD-SIN');
$book->increaseStock(1);
$loan = $book->checkOutTo($this->borrower);
$loan->markReturned(); // releases the claim → restores available
$output = $this->runOk(ShopAvailabilityCommand::class, [
'product' => 'CMD-SIN',
'--from' => '2026-05-01',
'--to' => '2026-05-31',
]);
$this->assertStringContainsString('Available 1', $output);
$this->assertSame(1, $book->fresh()->getAvailableStock());
}
#[Test]
public function shop_stocks_availability_day_view_for_unmanaged_book_shows_unlimited(): void
{
// manage_stock=false on a loanable book is the moonshiner "infinite
// copy" pattern: every borrower can take a copy at any time.
$book = $this->newBook('Compendium', 'CMD-CMP');
$book->manage_stock = false;
$book->save();
$output = $this->runOk(ShopAvailabilityCommand::class, [
'product' => 'CMD-CMP',
'--day' => '2026-05-14',
]);
$this->assertStringContainsString('Unlimited availability all day.', $output);
}
/* ───────────────────────────── helpers ─────────────────────────────── */
/**
* Count of loan-driven DECREASE rows on this book — mirrors what the
* "Used" column in shop:stocks renders.
*/
private function loanedDecreases(CmdLoanBook $book): int
{
return (int) abs((int) $book->stocks()
->withoutGlobalScope('willExpire')
->where('type', \Blax\Shop\Enums\StockType::DECREASE->value)
->where('status', \Blax\Shop\Enums\StockStatus::COMPLETED->value)
->sum('quantity'));
}
/**
* Extract the first numeric token from the value row that follows the
* ASSIGNED label row in the shop:stocks detail output. The detail view
* renders a single boxed row, so the first integer after "ASSIGNED" on
* the next non-empty line is the Assigned value.
*/
private function detailViewAssignedValue(string $output): ?int
{
$lines = explode("\n", $output);
$assignedLineIndex = null;
foreach ($lines as $i => $line) {
if (str_contains($line, 'ASSIGNED')) {
$assignedLineIndex = $i;
break;
}
}
if ($assignedLineIndex === null) {
return null;
}
// Walk forward to the next line that contains digits.
for ($i = $assignedLineIndex + 1; $i < count($lines); $i++) {
if (preg_match('/\b(\d+)\b/', $lines[$i], $m)) {
return (int) $m[1];
}
}
return null;
}
/**
* Pull the named row out of the shop:stocks overview table and parse the
* 4 trailing integer columns (Assigned / Used / Available / Claimed).
*
* @return array{assigned: int, used: int, available: int, claimed: int}|null
*/
private function overviewRow(string $output, string $needle): ?array
{
foreach (explode("\n", $output) as $line) {
if (! str_contains($line, $needle)) {
continue;
}
// Capture the four numeric columns at the tail of the row. Type
// and ID columns precede them; we anchor on the trailing
// "n | n | n | n" shape (the only column run that's all integers).
// The overview renders via $this->table(...) which uses plain ASCII
// pipes — not the box-drawing │ used by the detail/availability
// commands.
if (preg_match('/(\d+)\s*\|\s*(\d+|—)\s*\|\s*(\d+|∞)\s*\|\s*(\d+)\s*\|\s*$/u', $line, $m)) {
return [
'assigned' => (int) $m[1],
'used' => $m[2] === '—' ? 0 : (int) $m[2],
'available' => $m[3] === '∞' ? PHP_INT_MAX : (int) $m[3],
'claimed' => (int) $m[4],
];
}
}
return null;
}
}
/**
* Plug-n-pray loanable fixture, mirroring CheckOutToTest's LoanableBook but
* under a unique name so the two files can coexist in the same namespace.
*/
class CmdLoanBook extends Product
{
public const DEFAULT_TYPE = ProductType::LOANABLE;
protected $guarded = [];
}