create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $response = $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 4, 'lines' => [ ['category' => 'filament', 'description' => 'PLA black', 'quantity' => '200', 'quantity_unit' => 'g', 'unit_rate' => '18.000000', 'waste_quantity' => '10'], ['category' => 'filament', 'description' => 'PETG clear', 'quantity' => '50', 'quantity_unit' => 'g', 'unit_rate' => '22.000000'], ['category' => 'packaging', 'description' => 'Box', 'quantity' => '1', 'quantity_unit' => 'unit', 'unit_rate' => '0.650000'], ['category' => 'shipping', 'description' => 'Parcel', 'quantity' => '1', 'quantity_unit' => 'parcel', 'unit_rate' => '3.990000', 'units_per_parcel' => '4'], ], ]); $response->assertRedirect(); $snapshot = ProjectCostSnapshot::first(); $this->assertNotNull($snapshot); // (200g + 10g waste) * £18.00/kg = £3.78, plus 50g * £22.00/kg = £1.10. $this->assertSame('4.8800', (string) $snapshot->filament_total); $this->assertSame(4, $snapshot->lines()->count()); } public function test_missing_category_is_not_costed_rather_than_zero(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '0', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']], ]); $snapshot = ProjectCostSnapshot::first(); $this->assertSame('0.0000', (string) $snapshot->filament_total); $this->assertNull($snapshot->packaging_total); $this->assertNull($snapshot->shipping_total); } public function test_negative_quantity_is_rejected(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '-5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']], ])->assertSessionHasErrors('lines'); $this->assertDatabaseCount('project_cost_snapshots', 0); } public function test_zero_or_negative_batch_yield_is_rejected(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 0, 'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']], ])->assertSessionHasErrors('batch_units'); } public function test_invalid_currency_is_rejected(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'ZZZ', 'batch_units' => 1, 'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']], ])->assertSessionHasErrors('currency'); } public function test_actual_snapshot_never_overwrites_the_estimate(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '100', 'quantity_unit' => 'g', 'unit_rate' => '20.000000']], ]); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'actual', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '110', 'quantity_unit' => 'g', 'unit_rate' => '21.000000']], ]); $this->assertDatabaseCount('project_cost_snapshots', 2); $this->assertDatabaseHas('project_cost_snapshots', ['basis' => 'estimate', 'filament_total' => '2.0000']); $this->assertDatabaseHas('project_cost_snapshots', ['basis' => 'actual', 'filament_total' => '2.3100']); } public function test_non_admin_cannot_record_cost_snapshots(): void { $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs(User::factory()->create())->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']], ])->assertForbidden(); } public function test_forged_line_total_in_the_request_is_ignored_in_favour_of_the_computed_value(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [[ 'category' => 'filament', 'description' => 'PLA', 'quantity' => '100', 'quantity_unit' => 'g', 'unit_rate' => '20.000000', 'line_total' => '0.0001', ]], ]); $snapshot = ProjectCostSnapshot::first(); // 100g * £20.00/kg = £2.00, regardless of the forged line_total in the payload. $this->assertSame('2.0000', (string) $snapshot->filament_total); $this->assertSame('2.0000', (string) $snapshot->lines()->first()->line_total); } public function test_quantity_unit_must_match_the_category_allowlist(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '100', 'quantity_unit' => 'kg', 'unit_rate' => '20.000000']], ])->assertSessionHasErrors('lines'); $this->assertDatabaseCount('project_cost_snapshots', 0); } public function test_shipping_line_requires_a_positive_units_per_parcel(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [['category' => 'shipping', 'description' => 'Parcel', 'quantity' => '1', 'quantity_unit' => 'parcel', 'unit_rate' => '4.990000']], ])->assertSessionHasErrors('lines.0.units_per_parcel'); $this->assertDatabaseCount('project_cost_snapshots', 0); } public function test_combined_lines_exceeding_storable_precision_are_rejected_as_validation_not_a_server_error(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $response = $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $project), [ 'revision_id' => $revision->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [ ['category' => 'packaging', 'description' => 'Box A', 'quantity' => '1000000', 'quantity_unit' => 'unit', 'unit_rate' => '6000.000000'], ['category' => 'packaging', 'description' => 'Box B', 'quantity' => '1000000', 'quantity_unit' => 'unit', 'unit_rate' => '6000.000000'], ], ]); $response->assertSessionHasErrors('lines'); $response->assertStatus(302); $this->assertDatabaseCount('project_cost_snapshots', 0); } public function test_cannot_cost_a_revision_from_a_different_project(): void { $admin = User::factory()->create(['role' => 'admin']); $projectA = Project::factory()->create(); $projectB = Project::factory()->create(); $revisionOfB = ProjectRevision::factory()->for($projectB)->create(); $this->actingAs($admin)->post(route('projects.cost-snapshots.store', $projectA), [ 'revision_id' => $revisionOfB->id, 'basis' => 'estimate', 'currency' => 'GBP', 'batch_units' => 1, 'lines' => [['category' => 'filament', 'description' => 'PLA', 'quantity' => '5', 'quantity_unit' => 'g', 'unit_rate' => '18.000000']], ])->assertNotFound(); } }