create(['manage_stock' => true]); $product->increaseStock(10); $this->assertDatabaseHas('product_stocks', [ 'product_id' => $product->id, 'quantity' => 10, 'type' => 'increase', ]); } /** @test */ public function it_creates_stock_record_on_decrease() { $product = Product::factory()->create(['manage_stock' => true]); $product->increaseStock(20); $product->decreaseStock(5); $this->assertDatabaseHas('product_stocks', [ 'product_id' => $product->id, 'quantity' => -5, 'type' => 'decrease', ]); } /** @test */ public function stock_belongs_to_product() { $product = Product::factory()->create(['manage_stock' => true]); $product->increaseStock(10); $stock = $product->stocks()->first(); $this->assertInstanceOf(Product::class, $stock->product); $this->assertEquals($product->id, $stock->product->id); } /** @test */ public function product_has_many_stock_records() { $product = Product::factory()->create(['manage_stock' => true]); $product->increaseStock(10); $product->increaseStock(5); $product->decreaseStock(3); $this->assertCount(3, $product->stocks); } /** @test */ public function available_stock_considers_all_records() { $product = Product::factory()->create(['manage_stock' => true]); $product->increaseStock(50); $product->increaseStock(30); $product->decreaseStock(20); $this->assertEquals(60, $product->getAvailableStock()); } /** @test */ public function claim_reduces_available_stock() { $product = Product::factory()->withStocks(100)->create(); $claim = $product->claimStock(25); $this->assertEquals(75, $product->getAvailableStock()); $this->assertNotNull($claim); } /** @test */ public function releasing_claim_increases_available_stock() { $product = Product::factory()->withStocks(100)->create(); $claim = $product->claimStock(25); $this->assertEquals(75, $product->getAvailableStock()); $claim->release(); $this->assertEquals(100, $product->refresh()->getAvailableStock()); } /** @test */ public function permanent_claim_has_no_expiry() { $product = Product::factory()->withStocks(50)->create(); $claim = $product->claimStock(10); $this->assertNull($claim->expires_at); $this->assertTrue($claim->isPermanent()); } /** @test */ public function temporary_claim_has_expiry() { $product = Product::factory()->withStocks(50)->create(); $claim = $product->claimStock( quantity: 10, until: now()->addHours(2) ); $this->assertNotNull($claim->expires_at); $this->assertTrue($claim->isTemporary()); } /** @test */ public function claim_can_have_note() { $product = Product::factory()->withStocks(50)->create(); $note = 'Claimed for VIP customer'; $claim = $product->claimStock( quantity: 10, note: $note ); $this->assertEquals($note, $claim->note); } /** @test */ public function cannot_claim_more_than_available() { $product = Product::factory()->withStocks(10)->create(); $this->expectException(NotEnoughStockException::class); $product->claimStock(15); } /** @test */ public function pending_scope_returns_unreleased_claims() { $product = Product::factory()->withStocks(100)->create(); $pending = $product->claimStock(10); $released = $product->claimStock(5); $released->release(); $pendingClaims = ProductStock::pending()->get(); $this->assertTrue($pendingClaims->contains($pending)); $this->assertFalse($pendingClaims->contains($released)); } /** @test */ public function released_scope_returns_released_claims() { $product = Product::factory()->withStocks(100)->create(); $pending = $product->claimStock(10); $released = $product->claimStock(5); $released->release(); $releasedClaims = ProductStock::released()->get(); $this->assertFalse($releasedClaims->contains($pending)); $this->assertTrue($releasedClaims->contains($released)); } /** @test */ public function expired_claims_dont_affect_available_stock() { $product = Product::factory()->withStocks(100)->create(); $product->claimStock( quantity: 20, until: now()->subHour() ); // Expired claims should be counted in available stock $available = $product->claims()->get(); $this->assertEquals(0, $available->count()); } /** @test */ public function cannot_release_stock_twice() { $product = Product::factory()->withStocks(50)->create(); $claim = $product->claimStock(10); $this->assertTrue($claim->release()); $this->assertFalse($claim->release()); } /** @test */ public function stock_status_is_tracked() { $product = Product::factory()->create(['manage_stock' => true]); $product->increaseStock(10); $stock = $product->stocks()->first(); $this->assertEquals(StockStatus::COMPLETED, $stock->status); } /** @test */ public function product_without_stock_management_returns_max_stock() { $product = Product::factory()->create(['manage_stock' => false]); $available = $product->getAvailableStock(); $this->assertEquals(PHP_INT_MAX, $available); } /** @test */ public function product_without_stock_management_doesnt_create_records() { $product = Product::factory()->create(['manage_stock' => false]); $result = $product->increaseStock(10); $this->assertFalse($result); $this->assertCount(0, $product->stocks); } /** @test */ public function claim_without_stock_management_returns_null() { $product = Product::factory()->create(['manage_stock' => false]); $claim = $product->claimStock(10); $this->assertNull($claim); } /** @test */ public function available_stocks_attribute_accessor_works() { $product = Product::factory()->create(['manage_stock' => true]); $product->increaseStock(25); $product->increaseStock(15); $this->assertEquals(40, $product->AvailableStocks); } /** @test */ public function claims_method_filters_active_only() { $product = Product::factory()->withStocks(100)->create(); $active = $product->claimStock(10, until: now()->addDay()); $expired = $product->claimStock(5, until: now()->subDay()); $claims = $product->claims()->get(); $this->assertCount(1, $claims); $this->assertEquals($active->id, $claims->first()->id); } /** @test */ public function can_adjust_stock() { $product = Product::factory()->create(['manage_stock' => true]); $product->increaseStock(20); $this->assertEquals(20, $product->getAvailableStock()); $product->adjustStock( type: \Blax\Shop\Enums\StockType::DECREASE, quantity: 5 ); $this->assertEquals(15, $product->getAvailableStock()); $product->adjustStock( type: \Blax\Shop\Enums\StockType::INCREASE, quantity: 10 ); $this->assertEquals(25, $product->getAvailableStock()); // Also with until $product->adjustStock( type: \Blax\Shop\Enums\StockType::DECREASE, quantity: 5, until: now()->addDay() ); $this->assertEquals(20, $product->getAvailableStock()); $this->travel(23)->hours(); $this->assertEquals(20, $product->getAvailableStock()); $this->travel(2)->days(); $this->assertEquals(25, $product->getAvailableStock()); } /** @test */ public function it_can_claim_stock_with_claimed_from_date() { $product = Product::factory()->withStocks(100)->create(); $claimedFrom = now()->addDays(5); $until = now()->addDays(10); $claim = $product->claimStock( quantity: 20, from: $claimedFrom, until: $until ); $this->assertNotNull($claim); $this->assertEquals($claimedFrom->format('Y-m-d H:i:s'), $claim->claimed_from->format('Y-m-d H:i:s')); $this->assertEquals($until->format('Y-m-d H:i:s'), $claim->expires_at->format('Y-m-d H:i:s')); } /** @test */ public function it_can_check_available_stock_on_a_date() { $product = Product::factory()->withStocks(100)->create(); // Claim stock from day 5 to day 10 $product->claimStock( quantity: 30, from: now()->addDays(5), until: now()->addDays(10) ); // Should have full stock available before claimed_from date $availableOnDay3 = $product->availableOnDate(now()->addDays(3)); $this->assertEquals(100, $availableOnDay3); // Should have reduced stock during claimed period $availableOnDay7 = $product->availableOnDate(now()->addDays(7)); $this->assertEquals(70, $availableOnDay7); // Should have full stock available after expires_at date $availableOnDay12 = $product->availableOnDate(now()->addDays(12)); $this->assertEquals(100, $availableOnDay12); } /** @test */ public function it_can_handle_multiple_overlapping_claims_on_date() { $product = Product::factory()->withStocks(100)->create(); // First claim: days 5-10 $product->claimStock( quantity: 20, from: now()->addDays(5), until: now()->addDays(10) ); // Second claim: days 8-15 $product->claimStock( quantity: 30, from: now()->addDays(8), until: now()->addDays(15) ); // Day 6: only first claim is active $availableOnDay6 = $product->availableOnDate(now()->addDays(6)); $this->assertEquals(80, $availableOnDay6); // Day 9: both claims are active $availableOnDay9 = $product->availableOnDate(now()->addDays(9)); $this->assertEquals(50, $availableOnDay9); // Day 13: only second claim is active $availableOnDay13 = $product->availableOnDate(now()->addDays(13)); $this->assertEquals(70, $availableOnDay13); // Day 20: no claims active $availableOnDay20 = $product->availableOnDate(now()->addDays(20)); $this->assertEquals(100, $availableOnDay20); } /** @test */ public function it_handles_claims_without_claimed_from_as_immediately_claimed() { $product = Product::factory()->withStocks(100)->create(); // Claim without claimed_from (immediately claimed) $product->claimStock( quantity: 25, until: now()->addDays(10) ); // Should be claimed immediately $availableNow = $product->availableOnDate(now()); $this->assertEquals(75, $availableNow); // Should still be claimed on day 7 $availableOnDay7 = $product->availableOnDate(now()->addDays(7)); $this->assertEquals(75, $availableOnDay7); // Should be released after expiry $availableOnDay12 = $product->availableOnDate(now()->addDays(12)); $this->assertEquals(100, $availableOnDay12); } /** @test */ public function it_handles_permanent_claims_without_expires_at() { $product = Product::factory()->withStocks(100)->create(); // Permanent claim from day 5 onwards $product->claimStock( quantity: 40, from: now()->addDays(5) ); // Before claimed_from: full stock available $availableOnDay3 = $product->availableOnDate(now()->addDays(3)); $this->assertEquals(100, $availableOnDay3); // After claimed_from: reduced stock $availableOnDay10 = $product->availableOnDate(now()->addDays(10)); $this->assertEquals(60, $availableOnDay10); // Far future: still reduced (permanent claim) $availableOnDay100 = $product->availableOnDate(now()->addDays(100)); $this->assertEquals(60, $availableOnDay100); } /** @test */ public function available_on_date_scope_filters_correctly() { $product = Product::factory()->withStocks(100)->create(); // Create various claims $claim1 = $product->claimStock( quantity: 10, from: now()->addDays(5), until: now()->addDays(10) ); $claim2 = $product->claimStock( quantity: 15, from: now()->addDays(8), until: now()->addDays(15) ); $claim3 = $product->claimStock( quantity: 20, from: now()->addDays(20), until: now()->addDays(25) ); // Test scope on day 7 - should only include claim1 $claimsOnDay7 = \Blax\Shop\Models\ProductStock::availableOnDate(now()->addDays(7)) ->where('product_id', $product->id) ->get(); $this->assertCount(1, $claimsOnDay7); $this->assertEquals($claim1->id, $claimsOnDay7->first()->id); // Test scope on day 12 - should only include claim2 $claimsOnDay12 = \Blax\Shop\Models\ProductStock::availableOnDate(now()->addDays(12)) ->where('product_id', $product->id) ->get(); $this->assertCount(1, $claimsOnDay12); $this->assertEquals($claim2->id, $claimsOnDay12->first()->id); } }