255 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|