Files
HickLab3D_Dashboard/tests/Feature/ProjectReleaseTest.php
T

285 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\ProjectCostSnapshot;
use App\Models\ProjectFile;
use App\Models\ProjectListingLink;
use App\Models\ProjectRelease;
use App\Models\ProjectRevision;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class ProjectReleaseTest extends TestCase
{
use LazilyRefreshDatabase;
private function approvedRevisionWithCost(Project $project, int $revisionNumber = 1): array
{
$revision = ProjectRevision::factory()->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']);
}
/**
* 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_release_succeeds_when_lock_version_is_submitted_as_a_matching_string(): 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();
$this->assertNotNull($link->fresh()->live_release_id);
}
public function test_revert_succeeds_when_lock_version_is_submitted_as_a_matching_string(): 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]);
$this->actingAs($admin)->post(route('projects.listings.release', [$project, $link]), [
'revision_id' => $revision->id, 'cost_snapshot_id' => $snapshot->id, 'lock_version' => 0,
]);
$firstReleaseId = $link->fresh()->live_release_id;
$response = $this->actingAs($admin)->post(route('projects.listings.revert', [$project, $link]), [
'release_id' => $firstReleaseId, 'lock_version' => '1',
]);
$response->assertRedirect();
$this->assertSame(2, $link->fresh()->lock_version);
}
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);
}
}