12 - Add tests for allocation preview endpoint
This commit is contained in:
parent
4a6e69d33b
commit
a70dc036fe
2 changed files with 246 additions and 6 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
235
tests/Feature/ProjectionPreviewTest.php
Normal file
235
tests/Feature/ProjectionPreviewTest.php
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectionPreviewTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Scenario $scenario;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue