approved()->for($project)->create(['revision_number' => $number]); ProjectFile::factory()->for($revision, 'revision')->create(); return $revision; } public function test_admin_can_confirm_an_approved_revision_as_current(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = $this->approvedRevisionWithFile($project); $this->actingAs($admin) ->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]) ->assertRedirect(route('projects.show', $project)); $project->refresh(); $this->assertSame($revision->id, $project->current_revision_id); $this->assertSame(1, $project->lock_version); $this->assertDatabaseHas('project_events', [ 'project_id' => $project->id, 'event_type' => 'current_revision_confirmed', 'subject_type' => ProjectRevision::class, 'subject_id' => $revision->id, ]); $event = $project->events()->where('event_type', 'current_revision_confirmed')->first(); $this->assertSame(['previous_current_revision_id' => null, 'new_current_revision_id' => $revision->id], $event->metadata); } public function test_rejects_an_unapproved_revision(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(['revision_number' => 1]); ProjectFile::factory()->for($revision, 'revision')->create(); $this->actingAs($admin) ->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]) ->assertSessionHasErrors('revision_id'); $this->assertNull($project->fresh()->current_revision_id); } public function test_rejects_an_approved_revision_with_no_files(): void { // Approving through the normal endpoint already requires at least one file, // but the confirm endpoint must not trust that invariant blindly. $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]); $this->actingAs($admin) ->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]) ->assertSessionHasErrors('revision_id'); $this->assertNull($project->fresh()->current_revision_id); } public function test_rejects_a_revision_belonging_to_a_different_project(): void { $admin = User::factory()->create(['role' => 'admin']); $projectA = Project::factory()->create(); $projectB = Project::factory()->create(); $revision = $this->approvedRevisionWithFile($projectB); $this->actingAs($admin) ->post(route('projects.revisions.confirm-current', [$projectA, $revision]), ['lock_version' => 0]) ->assertNotFound(); $this->assertNull($projectA->fresh()->current_revision_id); } public function test_stale_lock_version_is_rejected_as_a_conflict(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = $this->approvedRevisionWithFile($project); $response = $this->actingAs($admin) ->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 5]); $response->assertStatus(409); $this->assertNull($project->fresh()->current_revision_id); } /** * A real HTML
POST body is always strings, never native PHP ints * (unlike the test client's array-based helpers used elsewhere in this * file). The matching current lock_version must still be accepted, not * rejected as a false conflict by a strict `!==` comparing a DB int to a * form string. */ public function test_confirm_succeeds_when_lock_version_is_submitted_as_a_matching_string(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = $this->approvedRevisionWithFile($project); $this->actingAs($admin) ->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => '0']) ->assertRedirect(route('projects.show', $project)); $this->assertSame($revision->id, $project->fresh()->current_revision_id); } public function test_clear_succeeds_when_lock_version_is_submitted_as_a_matching_string(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = $this->approvedRevisionWithFile($project); $this->actingAs($admin)->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]); $this->actingAs($admin) ->delete(route('projects.current-revision.clear', $project), ['lock_version' => '1']) ->assertRedirect(route('projects.show', $project)); $this->assertNull($project->fresh()->current_revision_id); } /** * A genuinely stale lock_version submitted as a string must still be * rejected as a conflict once both sides are compared numerically — * proves the fix does not accidentally accept every string as "matching". */ public function test_genuinely_stale_lock_version_submitted_as_a_string_is_still_rejected(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = $this->approvedRevisionWithFile($project); $response = $this->actingAs($admin) ->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => '5']); $response->assertStatus(409); $this->assertNull($project->fresh()->current_revision_id); } public function test_uploading_a_new_revision_does_not_promote_it_to_current(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = $this->approvedRevisionWithFile($project); $this->actingAs($admin)->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]); Storage::fake('project_files'); $stl = UploadedFile::fake()->createWithContent( 'part.stl', "solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n", ); $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => [$stl], 'roles' => ['body'], ]); $this->assertSame($revision->id, $project->fresh()->current_revision_id); } public function test_admin_can_clear_the_confirmed_current_revision(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = $this->approvedRevisionWithFile($project); $this->actingAs($admin)->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]); $this->actingAs($admin) ->delete(route('projects.current-revision.clear', $project), ['lock_version' => 1]) ->assertRedirect(route('projects.show', $project)); $project->refresh(); $this->assertNull($project->current_revision_id); $this->assertSame(2, $project->lock_version); $this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'current_revision_cleared']); } public function test_an_older_revision_can_be_re_confirmed_as_a_rollback_without_mutating_history(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revisionOne = $this->approvedRevisionWithFile($project, 1); $revisionTwo = $this->approvedRevisionWithFile($project, 2); $this->actingAs($admin)->post(route('projects.revisions.confirm-current', [$project, $revisionTwo]), ['lock_version' => 0]); $this->assertSame($revisionTwo->id, $project->fresh()->current_revision_id); $this->actingAs($admin) ->post(route('projects.revisions.confirm-current', [$project, $revisionOne]), ['lock_version' => 1]) ->assertRedirect(route('projects.show', $project)); $project->refresh(); $this->assertSame($revisionOne->id, $project->current_revision_id); $this->assertSame(2, $project->lock_version); // Neither revision row is mutated by the rollback — only the pointer moves. $this->assertSame('approved', $revisionOne->fresh()->status); $this->assertSame('approved', $revisionTwo->fresh()->status); $event = $project->events()->where('event_type', 'current_revision_confirmed')->latest('id')->first(); $this->assertSame(['previous_current_revision_id' => $revisionTwo->id, 'new_current_revision_id' => $revisionOne->id], $event->metadata); } public function test_guest_cannot_confirm_or_clear_current_revision(): void { $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]) ->assertRedirect(route('login')); $this->delete(route('projects.current-revision.clear', $project), ['lock_version' => 0]) ->assertRedirect(route('login')); } public function test_non_admin_cannot_confirm_or_clear_current_revision(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $this->actingAs($user) ->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]) ->assertForbidden(); $this->actingAs($user) ->delete(route('projects.current-revision.clear', $project), ['lock_version' => 0]) ->assertForbidden(); } public function test_missing_csrf_token_is_rejected_when_verification_actually_runs(): void { $this->app['env'] = 'local'; $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = $this->approvedRevisionWithFile($project); $response = $this->actingAs($admin) ->post(route('projects.revisions.confirm-current', [$project, $revision]), ['lock_version' => 0]); $response->assertStatus(419); } }