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

346 lines
14 KiB
PHP
Raw Permalink Normal View History

<?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) ─────────────── */
2026-05-17 12:09:03 +00:00
#[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 = [];
}