approved()->for($project)->create(['revision_number' => $revisionNumber]); ProjectFile::factory()->for($revision, 'revision')->create(); $snapshot = ProjectCostSnapshot::factory()->for($project)->for($revision, 'revision')->create([ 'grand_total' => '10.0000', 'filament_total' => '10.0000', 'packaging_total' => '0.0000', 'shipping_total' => '0.0000', ]); return [$revision, $snapshot]; } public function test_admin_can_release_an_approved_revision_to_a_listing(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); [$revision, $snapshot] = $this->approvedRevisionWithCost($project); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]); $response = $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0, ]); $response->assertRedirect(); $link->refresh(); $this->assertNotNull($link->live_release_id); $this->assertSame(1, $link->lock_version); $this->assertDatabaseHas('project_releases', ['project_id' => $project->id, 'revision_id' => $revision->id, 'action' => 'release']); } public function test_uploading_v2_does_not_move_the_live_pointer(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); [$v1, $snapshot1] = $this->approvedRevisionWithCost($project, 1); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0, ]); $link->refresh(); $firstReleaseId = $link->live_release_id; [$v2, $snapshot2] = $this->approvedRevisionWithCost($project, 2); $link->refresh(); $this->assertSame($firstReleaseId, $link->live_release_id); } public function test_releasing_v2_then_reverting_restores_v1_with_new_history_entry(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); [$v1, $snapshot1] = $this->approvedRevisionWithCost($project, 1); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0, ]); $link->refresh(); $firstRelease = $link->live_release_id; [$v2, $snapshot2] = $this->approvedRevisionWithCost($project, 2); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $v2->id, 'cost_snapshot_id' => $snapshot2->id, 'lock_version' => $link->lock_version, ]); $link->refresh(); $this->actingAs($admin)->post(route('projects.listings.revert', [$project, $link]), [ 'release_id' => $firstRelease, 'lock_version' => $link->lock_version, ])->assertRedirect(); $link->refresh(); $revertRelease = ProjectRelease::find($link->live_release_id); $this->assertSame('revert', $revertRelease->action); $this->assertSame($v1->id, $revertRelease->revision_id); $this->assertDatabaseCount('project_releases', 3); } public function test_optimistic_concurrency_rejects_stale_lock_version(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); [$v1, $snapshot1] = $this->approvedRevisionWithCost($project, 1); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0, ]); [$v2, $snapshot2] = $this->approvedRevisionWithCost($project, 2); $response = $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $v2->id, 'cost_snapshot_id' => $snapshot2->id, 'lock_version' => 0, ]); $response->assertStatus(409); } public function test_cannot_release_a_revision_from_a_different_project(): void { $admin = User::factory()->create(['role' => 'admin']); $projectA = Project::factory()->create(); $projectB = Project::factory()->create(); [$revisionOfB] = $this->approvedRevisionWithCost($projectB); $link = ProjectListingLink::factory()->for($projectA)->create(['lock_version' => 0]); $this->actingAs($admin)->post(route('projects.listings.release', [$projectA, $link]), [ 'revision_id' => $revisionOfB->id, 'cost_snapshot_id' => 999999, 'lock_version' => 0, ])->assertStatus(422); } public function test_cannot_release_an_unapproved_revision(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $draft = ProjectRevision::factory()->for($project)->create(['status' => 'draft']); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $draft->id, 'cost_snapshot_id' => 999999, 'lock_version' => 0, ])->assertStatus(422); } public function test_cannot_release_to_a_listing_link_from_another_project(): void { $admin = User::factory()->create(['role' => 'admin']); $projectA = Project::factory()->create(); $projectB = Project::factory()->create(); [$revisionOfA] = $this->approvedRevisionWithCost($projectA); $linkOfB = ProjectListingLink::factory()->for($projectB)->create(['lock_version' => 0]); $this->actingAs($admin)->post(route('projects.listings.release', [$projectA, $linkOfB]), [ 'revision_id' => $revisionOfA->id, 'lock_version' => 0, ])->assertNotFound(); } public function test_non_admin_cannot_release(): void { $project = Project::factory()->create(); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]); $this->actingAs(User::factory()->create())->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => 1, 'lock_version' => 0, ])->assertForbidden(); } public function test_release_requires_a_cost_snapshot(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]); ProjectFile::factory()->for($revision, 'revision')->create(); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $revision->id, 'lock_version' => 0, ])->assertSessionHasErrors('cost_snapshot_id'); $this->assertDatabaseCount('project_releases', 0); } public function test_release_rejects_an_incomplete_cost_snapshot(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]); ProjectFile::factory()->for($revision, 'revision')->create(); $incomplete = ProjectCostSnapshot::factory()->for($project)->for($revision, 'revision')->create([ 'grand_total' => '10.0000', 'filament_total' => '10.0000', 'packaging_total' => null, 'shipping_total' => null, ]); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0]); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $revision->id, 'cost_snapshot_id' => $incomplete->id, 'lock_version' => 0, ])->assertSessionHasErrors('cost_snapshot_id'); $this->assertDatabaseCount('project_releases', 0); } public function test_release_rejects_a_detached_listing_link(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); [$revision, $snapshot] = $this->approvedRevisionWithCost($project); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'detached_at' => now()]); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0, ])->assertSessionHasErrors('listing_link'); $this->assertDatabaseCount('project_releases', 0); } public function test_revert_rejects_a_detached_listing_link(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); [$v1, $snapshot1] = $this->approvedRevisionWithCost($project); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $v1->id, 'cost_snapshot_id' => $snapshot1->id, 'lock_version' => 0, ]); $link->refresh(); $firstRelease = $link->live_release_id; $link->update(['detached_at' => now()]); $this->actingAs($admin)->post(route('projects.listings.revert', [$project, $link]), [ 'release_id' => $firstRelease, 'lock_version' => $link->lock_version, ])->assertSessionHasErrors('listing_link'); } public function test_release_is_recorded_atomically_with_its_audit_event(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); [$revision, $snapshot] = $this->approvedRevisionWithCost($project); $link = ProjectListingLink::factory()->for($project)->create(['lock_version' => 0, 'live_release_id' => null]); Schema::drop('project_events'); $this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [ 'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0, ]); $this->assertDatabaseCount('project_releases', 0); $link->refresh(); $this->assertNull($link->live_release_id); $this->assertSame(0, $link->lock_version); } }