Files
HickLab3D_Dashboard/tests/Feature/ProjectCurrentRevisionTest.php
T

255 lines
11 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\ProjectFile;
use App\Models\ProjectRevision;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ProjectCurrentRevisionTest extends TestCase
{
use LazilyRefreshDatabase;
private function approvedRevisionWithFile(Project $project, int $number = 1): ProjectRevision
{
$revision = ProjectRevision::factory()->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 <form> 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);
}
}