laravel-shop/tests/Feature/Product/HistoricalAvailabilityTest.php

202 lines
7.7 KiB
PHP
Raw Permalink 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\Product;
use Blax\Shop\Enums\ProductStatus;
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 PHPUnit\Framework\Attributes\Test;
/**
* Regression coverage for the bug where stock ledger entries with no
* `expires_at` were treated as if they had always existed — so a DECREASE
* row created today retroactively reduced availability for every prior day.
*
* Real-world reproducer (from a librarian's report):
* 1. Book seeded with 5 copies at the start of the month
* 2. A member loans one copy on day 17
* 3. `shop:stocks:availability` for May rendered 4 available for ALL 31
* days — including May 116, which is wrong because the loan didn't
* exist yet on those days.
*
* The fix scopes COMPLETED stock entries to their `created_at` boundary in
* the historical-query paths ({@see HasStocks::calendarAvailability} and
* {@see HasStocks::availableOnDate}): an entry only contributes to
* availability on date $X if it was persisted on or before $X. The
* day-level comparison (against end-of-day of $X) means a row seeded
* mid-day still counts for queries on that same day.
*
* {@see HasStocks::getAvailableStock} is intentionally NOT gated — it's
* the "current snapshot" API and is called by {@see ProductStock::claim}
* with future/past booking dates, where the caller wants the live
* physical inventory. CLAIMED entries already model both ends of their
* window via `claimed_from` / `expires_at`, so this fix only touches the
* INCREASE/DECREASE/RETURN code path.
*/
class HistoricalAvailabilityTest extends TestCase
{
use RefreshDatabase;
private function newProduct(): Product
{
return Product::create([
'name' => 'Hyperion',
'sku' => 'HYP-HIST',
'type' => ProductType::SIMPLE,
'status' => ProductStatus::PUBLISHED,
'is_visible' => true,
'manage_stock' => true,
]);
}
#[Test]
public function decrease_today_does_not_retroactively_reduce_yesterday(): void
{
// Day 1: seed inventory. Day 10: loan one copy.
Carbon::setTestNow(Carbon::parse('2026-05-01 09:00:00'));
$product = $this->newProduct();
$product->increaseStock(5);
Carbon::setTestNow(Carbon::parse('2026-05-10 13:32:00'));
$product->decreaseStock(1);
// Availability on day 5 (before the loan): still 5 copies.
$this->assertSame(
5,
$product->availableOnDate(Carbon::parse('2026-05-05 12:00:00')),
'past dates must not be affected by a later DECREASE',
);
// Availability on day 10 after the loan happened: 4.
$this->assertSame(
4,
$product->availableOnDate(Carbon::parse('2026-05-10 14:00:00')),
'present-day availability reflects the loan',
);
// Availability on day 20 (no further activity): still 4.
$this->assertSame(
4,
$product->availableOnDate(Carbon::parse('2026-05-20 09:00:00')),
);
}
#[Test]
public function increase_today_does_not_retroactively_inflate_yesterday(): void
{
// Symmetric guard: stock added today shouldn't make yesterday richer.
Carbon::setTestNow(Carbon::parse('2026-05-01 09:00:00'));
$product = $this->newProduct();
$product->increaseStock(2);
Carbon::setTestNow(Carbon::parse('2026-05-15 10:00:00'));
$product->increaseStock(3); // restock — three more copies arrived
$this->assertSame(
2,
$product->availableOnDate(Carbon::parse('2026-05-10 12:00:00')),
'past dates only see the initial 2 copies',
);
$this->assertSame(
5,
$product->availableOnDate(Carbon::parse('2026-05-20 12:00:00')),
'after the restock, total is 5',
);
}
#[Test]
public function calendar_availability_reflects_the_actual_timeline(): void
{
// This is the user's reported scenario almost verbatim. The library
// had its 5 copies long before the queried month, so we seed in April
// — otherwise the INCREASE row's created_at would itself split day 1
// (legitimately) and complicate the assertions.
Carbon::setTestNow(Carbon::parse('2026-04-15 09:00:00'));
$product = $this->newProduct();
$product->increaseStock(5);
// Loan on May 17 13:32 — exactly as the bug report described.
Carbon::setTestNow(Carbon::parse('2026-05-17 13:32:00'));
$product->decreaseStock(1);
$calendar = $product->calendarAvailability(
Carbon::parse('2026-05-01 00:00:00'),
Carbon::parse('2026-05-31 23:59:59'),
);
$dates = $calendar['dates'];
// Days 116 (before the loan): full availability.
foreach (['2026-05-01', '2026-05-10', '2026-05-16'] as $day) {
$this->assertSame(
['min' => 5, 'max' => 5],
$dates[$day],
"calendar must show 5 available on {$day} (before the loan)",
);
}
// Day 17 (the loan day itself): the calendar uses day-level
// granularity for the created_at gate, so a stock change anywhere
// during the day counts at every event on that day. Result: 4 across
// the board, even though the actual transition happened at 13:32.
// This is a deliberate trade-off — instant-level granularity created
// test-fixture chaos for any seed-then-query-now pattern, since the
// calendar's day boundaries always landed before the seed timestamp.
$this->assertSame(
['min' => 4, 'max' => 4],
$dates['2026-05-17'],
);
// Days 1831 (loan still out, nothing returned): 4 available.
foreach (['2026-05-18', '2026-05-25', '2026-05-31'] as $day) {
$this->assertSame(
['min' => 4, 'max' => 4],
$dates[$day],
"calendar must show 4 available on {$day} (loan in effect)",
);
}
}
#[Test]
public function calendar_summary_reports_the_correct_min_and_max(): void
{
// 5 copies seeded before the queried month, 1 loaned mid-month →
// calendar should report max_available=5 (some days), min_available=4
// (after the loan). Before the fix, both would have been 4 because
// every day rendered 4-4.
Carbon::setTestNow(Carbon::parse('2026-04-15 09:00:00'));
$product = $this->newProduct();
$product->increaseStock(5);
Carbon::setTestNow(Carbon::parse('2026-05-17 13:32:00'));
$product->decreaseStock(1);
$calendar = $product->calendarAvailability(
Carbon::parse('2026-05-01 00:00:00'),
Carbon::parse('2026-05-31 23:59:59'),
);
$this->assertSame(5, $calendar['max_available'], 'peak availability for the month is 5');
$this->assertSame(4, $calendar['min_available'], 'trough availability is 4 after the loan');
}
#[Test]
public function available_on_date_in_the_future_anticipates_no_changes(): void
{
// Asking about a future date with no scheduled changes should return
// current availability — the loan persists, so 4 stays 4.
Carbon::setTestNow(Carbon::parse('2026-05-17 13:32:00'));
$product = $this->newProduct();
$product->increaseStock(5);
$product->decreaseStock(1);
$this->assertSame(
4,
$product->availableOnDate(Carbon::parse('2026-06-15 12:00:00')),
);
}
}