buckets/tests/Unit/PipelineAllocationServiceTest.php

342 lines
12 KiB
PHP

<?php
namespace Tests\Unit;
use App\Enums\BucketAllocationTypeEnum;
use App\Models\Bucket;
use App\Models\Scenario;
use App\Services\Projection\PipelineAllocationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PipelineAllocationServiceTest extends TestCase
{
use RefreshDatabase;
private PipelineAllocationService $service;
private Scenario $scenario;
protected function setUp(): void
{
parent::setUp();
$this->service = new PipelineAllocationService;
$this->scenario = Scenario::factory()->create();
}
public function test_allocates_to_single_fixed_bucket()
{
// Arrange: Single bucket with $500 limit
$bucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'name' => 'Emergency Fund',
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 50000,
'starting_amount' => 0,
'priority' => 1,
]);
// Act: Allocate $300
$draws = $this->service->allocateInflow($this->scenario, 30000);
// Assert: All money goes to emergency fund
$this->assertCount(1, $draws);
$this->assertEquals($bucket->id, $draws[0]->bucket_id);
$this->assertEquals(30000, $draws[0]->amount);
}
public function test_allocates_across_multiple_fixed_buckets_by_priority()
{
// Arrange: Three buckets with different priorities
$bucket1 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 20000,
'starting_amount' => 0,
'priority' => 1,
]);
$bucket2 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 30000,
'starting_amount' => 0,
'priority' => 2,
]);
$bucket3 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 15000,
'starting_amount' => 0,
'priority' => 3,
]);
// Act: Allocate $550 (should fill bucket1 + bucket2 + partial bucket3)
$draws = $this->service->allocateInflow($this->scenario, 55000);
// Assert: Allocation follows priority order
$this->assertCount(3, $draws);
// Bucket 1: fully filled
$this->assertEquals($bucket1->id, $draws[0]->bucket_id);
$this->assertEquals(20000, $draws[0]->amount);
// Bucket 2: fully filled
$this->assertEquals($bucket2->id, $draws[1]->bucket_id);
$this->assertEquals(30000, $draws[1]->amount);
// Bucket 3: partially filled
$this->assertEquals($bucket3->id, $draws[2]->bucket_id);
$this->assertEquals(5000, $draws[2]->amount);
}
public function test_percentage_bucket_gets_percentage_of_remaining_amount()
{
// Arrange: Fixed bucket + percentage bucket
$fixedBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 30000,
'starting_amount' => 0,
'priority' => 1,
]);
$percentageBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
'allocation_value' => 20.00, // 20%
'starting_amount' => 0,
'priority' => 2,
]);
// Act: Allocate $1000
$draws = $this->service->allocateInflow($this->scenario, 100000);
// Assert: Fixed gets $300, percentage gets 20% of remaining $700 = $140
$this->assertCount(2, $draws);
$this->assertEquals(30000, $draws[0]->amount); // Fixed bucket
$this->assertEquals(14000, $draws[1]->amount); // 20% of $700
}
public function test_unlimited_bucket_gets_all_remaining_amount()
{
// Arrange: Fixed + unlimited buckets
$fixedBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 50000,
'starting_amount' => 0,
'priority' => 1,
]);
$unlimitedBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
'allocation_value' => null,
'starting_amount' => 0,
'priority' => 2,
]);
// Act: Allocate $1500
$draws = $this->service->allocateInflow($this->scenario, 150000);
// Assert: Fixed gets $500, unlimited gets remaining $1000
$this->assertCount(2, $draws);
$this->assertEquals(50000, $draws[0]->amount);
$this->assertEquals(100000, $draws[1]->amount);
}
public function test_skips_buckets_with_zero_allocation()
{
// Arrange: Bucket that can't accept any money
$fullBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 0, // No capacity
'starting_amount' => 0,
'priority' => 1,
]);
$normalBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 30000,
'starting_amount' => 0,
'priority' => 2,
]);
// Act: Allocate $200
$draws = $this->service->allocateInflow($this->scenario, 20000);
// Assert: Only normal bucket gets allocation
$this->assertCount(1, $draws);
$this->assertEquals($normalBucket->id, $draws[0]->bucket_id);
$this->assertEquals(20000, $draws[0]->amount);
}
public function test_handles_complex_mixed_bucket_scenario()
{
// Arrange: All bucket types in priority order
$fixed1 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 100000,
'starting_amount' => 0,
'priority' => 1,
]);
$percentage1 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
'allocation_value' => 15.00, // 15%
'starting_amount' => 0,
'priority' => 2,
]);
$fixed2 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 50000,
'starting_amount' => 0,
'priority' => 3,
]);
$percentage2 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
'allocation_value' => 25.00, // 25%
'starting_amount' => 0,
'priority' => 4,
]);
$unlimited = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
'allocation_value' => null,
'starting_amount' => 0,
'priority' => 5,
]);
// Act: Allocate $5000
$draws = $this->service->allocateInflow($this->scenario, 500000);
// Assert: Complex allocation logic
$this->assertCount(5, $draws);
// Fixed1: gets $1000 (full capacity)
$this->assertEquals(100000, $draws[0]->amount);
// Percentage1: gets 15% of remaining $4000 = $600
$this->assertEquals(60000, $draws[1]->amount);
// Fixed2: gets $500 (full capacity)
$this->assertEquals(50000, $draws[2]->amount);
// Remaining after fixed allocations: $5000 - $1000 - $600 - $500 = $2900
// Percentage2: gets 25% of remaining $2900 = $725
$this->assertEquals(72500, $draws[3]->amount);
// Unlimited: gets remaining $2900 - $725 = $2175
$this->assertEquals(217500, $draws[4]->amount);
}
public function test_returns_empty_array_when_no_buckets()
{
// Act: Allocate to scenario with no buckets
$draws = $this->service->allocateInflow($this->scenario, 100000);
// Assert: No allocations made
$this->assertEmpty($draws);
}
public function test_returns_empty_array_when_amount_is_zero()
{
// Arrange: Create bucket
Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 50000,
'starting_amount' => 0,
'priority' => 1,
]);
// Act: Allocate $0
$draws = $this->service->allocateInflow($this->scenario, 0);
// Assert: No allocations made
$this->assertEmpty($draws);
}
public function test_handles_negative_amount_gracefully()
{
// Arrange: Create bucket
Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 50000,
'starting_amount' => 0,
'priority' => 1,
]);
// Act: Allocate negative amount
$draws = $this->service->allocateInflow($this->scenario, -10000);
// Assert: No allocations made
$this->assertEmpty($draws);
}
public function test_respects_bucket_priority_order()
{
// Arrange: Buckets in non-sequential priority order
$bucket3 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 10000,
'starting_amount' => 0,
'priority' => 10, // Higher number
]);
$bucket1 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 20000,
'starting_amount' => 0,
'priority' => 1, // Lower number (higher priority)
]);
$bucket2 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 15000,
'starting_amount' => 0,
'priority' => 5, // Middle
]);
// Act: Allocate $250
$draws = $this->service->allocateInflow($this->scenario, 25000);
// Assert: Priority order respected (1, 5, 10)
$this->assertCount(2, $draws);
$this->assertEquals($bucket1->id, $draws[0]->bucket_id); // Priority 1 first
$this->assertEquals(20000, $draws[0]->amount);
$this->assertEquals($bucket2->id, $draws[1]->bucket_id); // Priority 5 second
$this->assertEquals(5000, $draws[1]->amount); // Partial fill
}
public function test_percentage_allocation_with_insufficient_remaining_amount()
{
// Arrange: Large fixed bucket + percentage bucket
$fixedBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 95000,
'starting_amount' => 0,
'priority' => 1,
]);
$percentageBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
'allocation_value' => 20.00, // 20%
'starting_amount' => 0,
'priority' => 2,
]);
// Act: Allocate $1000 (only $50 left after fixed)
$draws = $this->service->allocateInflow($this->scenario, 100000);
// Assert: Percentage gets 20% of remaining $50 = $10
$this->assertCount(2, $draws);
$this->assertEquals(95000, $draws[0]->amount);
$this->assertEquals(1000, $draws[1]->amount); // 20% of $50
}
}