buckets/tests/Unit/PipelineAllocationServiceTest.php

345 lines
13 KiB
PHP

<?php
namespace Tests\Unit;
use App\Enums\BucketAllocationType;
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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 500.00,
'starting_amount' => 0,
'priority' => 1
]);
// Act: Allocate $300
$draws = $this->service->allocateInflow($this->scenario, 300.00);
// Assert: All money goes to emergency fund
$this->assertCount(1, $draws);
$this->assertEquals($bucket->id, $draws[0]->bucket_id);
$this->assertEquals(300.00, $draws[0]->amount);
$this->assertEquals(1, $draws[0]->priority_order);
}
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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 200.00,
'starting_amount' => 0,
'priority' => 1
]);
$bucket2 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 300.00,
'starting_amount' => 0,
'priority' => 2
]);
$bucket3 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 150.00,
'starting_amount' => 0,
'priority' => 3
]);
// Act: Allocate $550 (should fill bucket1 + bucket2 + partial bucket3)
$draws = $this->service->allocateInflow($this->scenario, 550.00);
// Assert: Allocation follows priority order
$this->assertCount(3, $draws);
// Bucket 1: fully filled
$this->assertEquals($bucket1->id, $draws[0]->bucket_id);
$this->assertEquals(200.00, $draws[0]->amount);
$this->assertEquals(1, $draws[0]->priority_order);
// Bucket 2: fully filled
$this->assertEquals($bucket2->id, $draws[1]->bucket_id);
$this->assertEquals(300.00, $draws[1]->amount);
$this->assertEquals(2, $draws[1]->priority_order);
// Bucket 3: partially filled
$this->assertEquals($bucket3->id, $draws[2]->bucket_id);
$this->assertEquals(50.00, $draws[2]->amount);
$this->assertEquals(3, $draws[2]->priority_order);
}
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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 300.00,
'starting_amount' => 0,
'priority' => 1
]);
$percentageBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::PERCENTAGE,
'allocation_value' => 20.00, // 20%
'starting_amount' => 0,
'priority' => 2
]);
// Act: Allocate $1000
$draws = $this->service->allocateInflow($this->scenario, 1000.00);
// Assert: Fixed gets $300, percentage gets 20% of remaining $700 = $140
$this->assertCount(2, $draws);
$this->assertEquals(300.00, $draws[0]->amount); // Fixed bucket
$this->assertEquals(140.00, $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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 500.00,
'starting_amount' => 0,
'priority' => 1
]);
$unlimitedBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::UNLIMITED,
'allocation_value' => null,
'starting_amount' => 0,
'priority' => 2
]);
// Act: Allocate $1500
$draws = $this->service->allocateInflow($this->scenario, 1500.00);
// Assert: Fixed gets $500, unlimited gets remaining $1000
$this->assertCount(2, $draws);
$this->assertEquals(500.00, $draws[0]->amount);
$this->assertEquals(1000.00, $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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 0, // No capacity
'starting_amount' => 0,
'priority' => 1
]);
$normalBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 300.00,
'starting_amount' => 0,
'priority' => 2
]);
// Act: Allocate $200
$draws = $this->service->allocateInflow($this->scenario, 200.00);
// Assert: Only normal bucket gets allocation
$this->assertCount(1, $draws);
$this->assertEquals($normalBucket->id, $draws[0]->bucket_id);
$this->assertEquals(200.00, $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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 1000.00,
'starting_amount' => 0,
'priority' => 1
]);
$percentage1 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::PERCENTAGE,
'allocation_value' => 15.00, // 15%
'starting_amount' => 0,
'priority' => 2
]);
$fixed2 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 500.00,
'starting_amount' => 0,
'priority' => 3
]);
$percentage2 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::PERCENTAGE,
'allocation_value' => 25.00, // 25%
'starting_amount' => 0,
'priority' => 4
]);
$unlimited = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::UNLIMITED,
'allocation_value' => null,
'starting_amount' => 0,
'priority' => 5
]);
// Act: Allocate $5000
$draws = $this->service->allocateInflow($this->scenario, 5000.00);
// Assert: Complex allocation logic
$this->assertCount(5, $draws);
// Fixed1: gets $1000 (full capacity)
$this->assertEquals(1000.00, $draws[0]->amount);
// Percentage1: gets 15% of remaining $4000 = $600
$this->assertEquals(600.00, $draws[1]->amount);
// Fixed2: gets $500 (full capacity)
$this->assertEquals(500.00, $draws[2]->amount);
// Remaining after fixed allocations: $5000 - $1000 - $600 - $500 = $2900
// Percentage2: gets 25% of remaining $2900 = $725
$this->assertEquals(725.00, $draws[3]->amount);
// Unlimited: gets remaining $2900 - $725 = $2175
$this->assertEquals(2175.00, $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, 1000.00);
// 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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 500.00,
'starting_amount' => 0,
'priority' => 1
]);
// Act: Allocate $0
$draws = $this->service->allocateInflow($this->scenario, 0.00);
// 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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 500.00,
'starting_amount' => 0,
'priority' => 1
]);
// Act: Allocate negative amount
$draws = $this->service->allocateInflow($this->scenario, -100.00);
// 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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 100.00,
'starting_amount' => 0,
'priority' => 10 // Higher number
]);
$bucket1 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 200.00,
'starting_amount' => 0,
'priority' => 1 // Lower number (higher priority)
]);
$bucket2 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 150.00,
'starting_amount' => 0,
'priority' => 5 // Middle
]);
// Act: Allocate $250
$draws = $this->service->allocateInflow($this->scenario, 250.00);
// Assert: Priority order respected (1, 5, 10)
$this->assertCount(2, $draws);
$this->assertEquals($bucket1->id, $draws[0]->bucket_id); // Priority 1 first
$this->assertEquals(200.00, $draws[0]->amount);
$this->assertEquals($bucket2->id, $draws[1]->bucket_id); // Priority 5 second
$this->assertEquals(50.00, $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' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 950.00,
'starting_amount' => 0,
'priority' => 1
]);
$percentageBucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationType::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, 1000.00);
// Assert: Percentage gets 20% of remaining $50 = $10
$this->assertCount(2, $draws);
$this->assertEquals(950.00, $draws[0]->amount);
$this->assertEquals(10.00, $draws[1]->amount); // 20% of $50
}
}