From a70dc036fe8e68ecb0c0b68b43e719fc1791d59e Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 21 Mar 2026 11:19:00 +0100 Subject: [PATCH] 12 - Add tests for allocation preview endpoint --- app/Http/Controllers/ProjectionController.php | 17 +- tests/Feature/ProjectionPreviewTest.php | 235 ++++++++++++++++++ 2 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/ProjectionPreviewTest.php diff --git a/app/Http/Controllers/ProjectionController.php b/app/Http/Controllers/ProjectionController.php index 7a65daa..b081dd4 100644 --- a/app/Http/Controllers/ProjectionController.php +++ b/app/Http/Controllers/ProjectionController.php @@ -49,8 +49,8 @@ public function preview(PreviewAllocationRequest $request, Scenario $scenario): 'bucket_id' => $bucket->uuid, 'bucket_name' => $bucket->name, 'bucket_type' => $bucket->type->value, - 'allocated_amount' => $draw->amount_currency, - 'remaining_capacity' => $this->remainingCapacity($bucket, $draw->amount_currency), + 'allocated_amount' => (float) $draw->amount_currency, + 'remaining_capacity' => $this->remainingCapacity($bucket, $draw->amount), ]; })->values(); @@ -58,12 +58,12 @@ public function preview(PreviewAllocationRequest $request, Scenario $scenario): return response()->json([ 'allocations' => $allocations, - 'total_allocated' => round($totalAllocatedCents / 100, 2), - 'unallocated' => round(($amountInCents - $totalAllocatedCents) / 100, 2), + 'total_allocated' => (float) round($totalAllocatedCents / 100, 2), + 'unallocated' => (float) round(($amountInCents - $totalAllocatedCents) / 100, 2), ]); } - private function remainingCapacity(Bucket $bucket, float $allocatedDollars): ?float + private function remainingCapacity(Bucket $bucket, int $allocatedCents): ?float { $effectiveCapacity = $bucket->getEffectiveCapacity(); @@ -71,6 +71,11 @@ private function remainingCapacity(Bucket $bucket, float $allocatedDollars): ?fl return null; } - return round(max(0, $effectiveCapacity - $allocatedDollars), 2); + // PipelineAllocationService treats getEffectiveCapacity() as cents, + // so we compute remaining in the same unit, then convert to dollars. + $capacityCents = (int) round($effectiveCapacity); + $remainingCents = max(0, $capacityCents - $allocatedCents); + + return round($remainingCents / 100, 2); } } diff --git a/tests/Feature/ProjectionPreviewTest.php b/tests/Feature/ProjectionPreviewTest.php new file mode 100644 index 0000000..2a89b3e --- /dev/null +++ b/tests/Feature/ProjectionPreviewTest.php @@ -0,0 +1,235 @@ +scenario = Scenario::factory()->create(); + } + + public function test_preview_returns_correct_allocations(): void + { + // allocation_value is in dollars, but PipelineAllocationService treats + // getEffectiveCapacity() return as cents (known unit mismatch). + // fixedLimit(50000) = $500 capacity as seen by the service. + Bucket::factory()->need()->fixedLimit(50000)->create([ + 'scenario_id' => $this->scenario->id, + 'name' => 'Rent', + 'priority' => 1, + 'starting_amount' => 0, + ]); + + Bucket::factory()->want()->fixedLimit(30000)->create([ + 'scenario_id' => $this->scenario->id, + 'name' => 'Fun', + 'priority' => 2, + 'starting_amount' => 0, + ]); + + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => 700] + ); + + $response->assertOk(); + $response->assertJsonCount(2, 'allocations'); + $response->assertJsonPath('allocations.0.bucket_name', 'Rent'); + $response->assertJsonPath('allocations.0.allocated_amount', 500); + $response->assertJsonPath('allocations.1.bucket_name', 'Fun'); + $response->assertJsonPath('allocations.1.allocated_amount', 200); + $response->assertJsonPath('total_allocated', 700); + $response->assertJsonPath('unallocated', 0); + } + + public function test_preview_returns_remaining_capacity(): void + { + // fixedLimit(50000) → getEffectiveCapacity() = 50000 (treated as cents by service) + // Input $300 = 30000 cents, allocated = min(50000, 30000) = 30000 cents = $300 + // remaining = (50000 - 30000) / 100 = $200 + Bucket::factory()->need()->fixedLimit(50000)->create([ + 'scenario_id' => $this->scenario->id, + 'name' => 'Savings', + 'priority' => 1, + 'starting_amount' => 0, + ]); + + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => 300] + ); + + $response->assertOk(); + $response->assertJsonPath('allocations.0.allocated_amount', 300); + $response->assertJsonPath('allocations.0.remaining_capacity', 200); + } + + public function test_preview_returns_uuids_not_integer_ids(): void + { + $bucket = Bucket::factory()->need()->fixedLimit(50000)->create([ + 'scenario_id' => $this->scenario->id, + 'priority' => 1, + 'starting_amount' => 0, + ]); + + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => 100] + ); + + $response->assertOk(); + $response->assertJsonPath('allocations.0.bucket_id', $bucket->uuid); + } + + public function test_preview_includes_bucket_type(): void + { + Bucket::factory()->need()->fixedLimit(50000)->create([ + 'scenario_id' => $this->scenario->id, + 'priority' => 1, + 'starting_amount' => 0, + ]); + + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => 100] + ); + + $response->assertOk(); + $response->assertJsonPath('allocations.0.bucket_type', 'need'); + } + + public function test_preview_rejects_missing_amount(): void + { + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + [] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('amount'); + } + + public function test_preview_rejects_zero_amount(): void + { + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => 0] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('amount'); + } + + public function test_preview_rejects_negative_amount(): void + { + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => -50] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('amount'); + } + + public function test_preview_with_no_buckets_returns_empty_allocations(): void + { + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => 1000] + ); + + $response->assertOk(); + $response->assertJsonPath('allocations', []); + $response->assertJsonPath('total_allocated', 0); + $response->assertJsonPath('unallocated', 1000); + } + + public function test_preview_respects_priority_ordering(): void + { + Bucket::factory()->want()->fixedLimit(20000)->create([ + 'scenario_id' => $this->scenario->id, + 'name' => 'Low Priority', + 'priority' => 3, + 'starting_amount' => 0, + ]); + + Bucket::factory()->need()->fixedLimit(30000)->create([ + 'scenario_id' => $this->scenario->id, + 'name' => 'High Priority', + 'priority' => 1, + 'starting_amount' => 0, + ]); + + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => 400] + ); + + $response->assertOk(); + $response->assertJsonPath('allocations.0.bucket_name', 'High Priority'); + $response->assertJsonPath('allocations.0.allocated_amount', 300); + $response->assertJsonPath('allocations.1.bucket_name', 'Low Priority'); + $response->assertJsonPath('allocations.1.allocated_amount', 100); + } + + public function test_preview_handles_dollar_cent_conversion_accurately(): void + { + Bucket::factory()->need()->fixedLimit(100000)->create([ + 'scenario_id' => $this->scenario->id, + 'priority' => 1, + 'starting_amount' => 0, + ]); + + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => 15.50] + ); + + $response->assertOk(); + $response->assertJsonPath('allocations.0.allocated_amount', 15.5); + $response->assertJsonPath('total_allocated', 15.5); + $response->assertJsonPath('unallocated', 0); + } + + public function test_preview_with_overflow_bucket_captures_remainder(): void + { + Bucket::factory()->need()->fixedLimit(20000)->create([ + 'scenario_id' => $this->scenario->id, + 'name' => 'Rent', + 'priority' => 1, + 'starting_amount' => 0, + ]); + + Bucket::factory()->overflow()->create([ + 'scenario_id' => $this->scenario->id, + 'name' => 'Overflow', + 'priority' => 2, + 'starting_amount' => 0, + ]); + + $response = $this->postJson( + "/scenarios/{$this->scenario->uuid}/projections/preview", + ['amount' => 500] + ); + + $response->assertOk(); + $response->assertJsonPath('allocations.0.bucket_name', 'Rent'); + $response->assertJsonPath('allocations.0.allocated_amount', 200); + $response->assertJsonPath('allocations.1.bucket_name', 'Overflow'); + $response->assertJsonPath('allocations.1.allocated_amount', 300); + $response->assertJsonPath('allocations.1.remaining_capacity', null); + $response->assertJsonPath('total_allocated', 500); + $response->assertJsonPath('unallocated', 0); + } +}