buckets/tests/Unit/ProjectionGeneratorServiceTest.php

317 lines
12 KiB
PHP
Raw Normal View History

2025-12-31 02:34:30 +01:00
<?php
namespace Tests\Unit;
use App\Enums\BucketAllocationTypeEnum;
use App\Enums\StreamFrequencyEnum;
use App\Enums\StreamTypeEnum;
use App\Models\Bucket;
use App\Models\Scenario;
use App\Models\Stream;
use App\Services\Projection\PipelineAllocationService;
use App\Services\Projection\ProjectionGeneratorService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProjectionGeneratorServiceTest extends TestCase
{
use RefreshDatabase;
private ProjectionGeneratorService $service;
private Scenario $scenario;
protected function setUp(): void
{
parent::setUp();
$this->service = new ProjectionGeneratorService(new PipelineAllocationService());
$this->scenario = Scenario::factory()->create();
// Set a fixed "now" for consistent testing
Carbon::setTestNow('2026-01-01');
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_generates_daily_income_stream_projections()
{
// Arrange: Daily income stream of $100
$stream = Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'type' => StreamTypeEnum::INCOME,
'frequency' => StreamFrequencyEnum::DAILY,
'amount' => 10000, // $100 in cents
'start_date' => Carbon::parse('2026-01-01'),
'end_date' => null,
]);
// Act: Generate projections for 5 days
$projections = $this->service->generateProjections(
$this->scenario,
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-05')
);
// Assert: Should have 5 inflows
$this->assertCount(5, $projections['inflows']);
$this->assertEquals(10000, $projections['inflows'][0]->amount);
$this->assertEquals('2026-01-01', $projections['inflows'][0]->date->format('Y-m-d'));
$this->assertEquals('2026-01-05', $projections['inflows'][4]->date->format('Y-m-d'));
}
public function test_generates_weekly_expense_stream_projections()
{
// Arrange: Weekly expense stream of $50 on Mondays
$bucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
]);
$stream = Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'bucket_id' => $bucket->id,
'type' => StreamTypeEnum::EXPENSE,
'frequency' => StreamFrequencyEnum::WEEKLY,
'amount' => 5000, // $50 in cents
'start_date' => Carbon::parse('2026-01-01'), // Monday
'end_date' => null,
]);
// Act: Generate projections for 2 weeks
$projections = $this->service->generateProjections(
$this->scenario,
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-14')
);
// Assert: Should have 2 outflows (Jan 1 and Jan 8)
$this->assertCount(2, $projections['outflows']);
$this->assertEquals(5000, $projections['outflows'][0]->amount);
$this->assertEquals('2026-01-01', $projections['outflows'][0]->date->format('Y-m-d'));
$this->assertEquals('2026-01-08', $projections['outflows'][1]->date->format('Y-m-d'));
}
public function test_allocates_income_immediately_to_buckets()
{
// Arrange: Income stream and buckets with priority
$bucket1 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_value' => 30000,
'starting_amount' => 0,
'priority' => 1,
]);
$bucket2 = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
'allocation_value' => null,
'starting_amount' => 0,
'priority' => 2,
]);
$stream = Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'type' => StreamTypeEnum::INCOME,
'frequency' => StreamFrequencyEnum::DAILY,
'amount' => 10000, // $100 daily
'start_date' => Carbon::parse('2026-01-01'),
]);
// Act: Generate projections for 5 days ($500 total income)
$projections = $this->service->generateProjections(
$this->scenario,
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-05')
);
// Assert: Draws should be created for each day's income
$this->assertCount(5, $projections['inflows']);
$this->assertGreaterThan(0, $projections['draws']->count());
// First 3 days should fill bucket1 ($300)
$bucket1Draws = $projections['draws']->where('bucket_id', $bucket1->id);
$this->assertEquals(30000, $bucket1Draws->sum('amount')); // $300 total
// Remaining $200 should go to bucket2
$bucket2Draws = $projections['draws']->where('bucket_id', $bucket2->id);
$this->assertEquals(20000, $bucket2Draws->sum('amount')); // $200 total
}
public function test_respects_stream_start_and_end_dates()
{
// Arrange: Stream that starts mid-period and ends before period ends
$stream = Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'type' => StreamTypeEnum::INCOME,
'frequency' => StreamFrequencyEnum::DAILY,
'amount' => 10000,
'start_date' => Carbon::parse('2026-01-03'),
'end_date' => Carbon::parse('2026-01-07'),
]);
// Act: Generate projections for longer period
$projections = $this->service->generateProjections(
$this->scenario,
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-10')
);
// Assert: Should only have 5 inflows (Jan 3-7)
$this->assertCount(5, $projections['inflows']);
$this->assertEquals('2026-01-03', $projections['inflows'][0]->date->format('Y-m-d'));
$this->assertEquals('2026-01-07', $projections['inflows'][4]->date->format('Y-m-d'));
}
public function test_handles_monthly_streams_correctly()
{
// Arrange: Monthly income on the 15th
$stream = Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'type' => StreamTypeEnum::INCOME,
'frequency' => StreamFrequencyEnum::MONTHLY,
'amount' => 200000, // $2000
'start_date' => Carbon::parse('2026-01-15'),
]);
// Act: Generate projections for 3 months
$projections = $this->service->generateProjections(
$this->scenario,
Carbon::parse('2026-01-01'),
Carbon::parse('2026-03-31')
);
// Assert: Should have 3 inflows (Jan 15, Feb 15, Mar 15)
$this->assertCount(3, $projections['inflows']);
$this->assertEquals('2026-01-15', $projections['inflows'][0]->date->format('Y-m-d'));
$this->assertEquals('2026-02-15', $projections['inflows'][1]->date->format('Y-m-d'));
$this->assertEquals('2026-03-15', $projections['inflows'][2]->date->format('Y-m-d'));
}
public function test_processes_multiple_streams_on_same_day()
{
// Arrange: Two income streams that fire on the same day
$bucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
'starting_amount' => 0,
'priority' => 1,
]);
$stream1 = Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'type' => StreamTypeEnum::INCOME,
'frequency' => StreamFrequencyEnum::DAILY,
'amount' => 10000, // $100
'start_date' => Carbon::parse('2026-01-01'),
]);
$stream2 = Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'type' => StreamTypeEnum::INCOME,
'frequency' => StreamFrequencyEnum::DAILY,
'amount' => 5000, // $50
'start_date' => Carbon::parse('2026-01-01'),
]);
// Act: Generate projections for 1 day
$projections = $this->service->generateProjections(
$this->scenario,
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-01')
);
// Assert: Should have 2 inflows and draws totaling $150
$this->assertCount(2, $projections['inflows']);
$this->assertEquals(15000, $projections['inflows']->sum('amount')); // $150 total
$this->assertEquals(15000, $projections['draws']->sum('amount')); // All allocated
}
public function test_handles_mixed_income_and_expense_streams()
{
// Arrange: Income and expense streams
$bucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
'starting_amount' => 0,
'priority' => 1,
]);
$incomeStream = Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'type' => StreamTypeEnum::INCOME,
'frequency' => StreamFrequencyEnum::DAILY,
'amount' => 10000, // $100
'start_date' => Carbon::parse('2026-01-01'),
]);
$expenseStream = Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'bucket_id' => $bucket->id,
'type' => StreamTypeEnum::EXPENSE,
'frequency' => StreamFrequencyEnum::DAILY,
'amount' => 3000, // $30
'start_date' => Carbon::parse('2026-01-01'),
]);
// Act: Generate projections for 5 days
$projections = $this->service->generateProjections(
$this->scenario,
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-05')
);
// Assert: Should have both inflows and outflows
$this->assertCount(5, $projections['inflows']);
$this->assertCount(5, $projections['outflows']);
$this->assertEquals(50000, $projections['inflows']->sum('amount')); // $500 income
$this->assertEquals(15000, $projections['outflows']->sum('amount')); // $150 expenses
$this->assertEquals(50000, $projections['draws']->sum('amount')); // All income allocated
}
public function test_summary_calculations_are_accurate()
{
// Arrange: Income and expense streams
$bucket = Bucket::factory()->create([
'scenario_id' => $this->scenario->id,
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
'starting_amount' => 0,
'priority' => 1,
]);
Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'type' => StreamTypeEnum::INCOME,
'frequency' => StreamFrequencyEnum::DAILY,
'amount' => 10000, // $100
'start_date' => Carbon::parse('2026-01-01'),
]);
Stream::factory()->create([
'scenario_id' => $this->scenario->id,
'bucket_id' => $bucket->id,
'type' => StreamTypeEnum::EXPENSE,
'frequency' => StreamFrequencyEnum::DAILY,
'amount' => 3000, // $30
'start_date' => Carbon::parse('2026-01-01'),
]);
// Act: Generate projections for 10 days
$projections = $this->service->generateProjections(
$this->scenario,
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-10')
);
// Assert: Summary should be accurate
$this->assertEquals(100000, $projections['summary']['total_inflow']); // $1000
$this->assertEquals(30000, $projections['summary']['total_outflow']); // $300
$this->assertEquals(100000, $projections['summary']['total_allocated']); // $1000
$this->assertEquals(70000, $projections['summary']['net_cashflow']); // $700
}
}