Add project listing selector, confirmed revision and private 3MF renders
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Models\ProjectFile;
|
||||
use App\Models\ProjectFileRender;
|
||||
use App\Models\ProjectRevision;
|
||||
use App\Models\User;
|
||||
use App\Services\ThreeMfGeometryExtractor;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ProjectFileRenderTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
||||
|
||||
private function validModelXml(): string
|
||||
{
|
||||
return '<?xml version="1.0"?><model xmlns="'.self::NS.'"><resources>
|
||||
<object id="1" type="model">
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/>
|
||||
<vertex x="10" y="0" z="0"/>
|
||||
<vertex x="0" y="10" z="0"/>
|
||||
</vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles>
|
||||
</mesh>
|
||||
</object>
|
||||
</resources><build><item objectid="1"/></build></model>';
|
||||
}
|
||||
|
||||
private function validThreeMfBytes(): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->validModelXml());
|
||||
$zip->addFromString('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>');
|
||||
$zip->close();
|
||||
$bytes = file_get_contents($path);
|
||||
unlink($path);
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
private function maliciousThreeMfBytes(): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->validModelXml());
|
||||
$zip->addFromString('bomb.bin', str_repeat("\0", 20 * 1024 * 1024));
|
||||
$zip->setCompressionName('bomb.bin', ZipArchive::CM_DEFLATE, 9);
|
||||
$zip->close();
|
||||
$bytes = file_get_contents($path);
|
||||
unlink($path);
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Project, 1: ProjectFile}
|
||||
*/
|
||||
private function projectWith3mfFile(string $bytes): array
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$key = 'source.3mf';
|
||||
Storage::disk('project_files')->put($key, $bytes);
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create([
|
||||
'extension' => '3mf', 'media_type' => 'model/3mf', 'storage_key' => $key, 'original_filename' => 'widget.3mf',
|
||||
]);
|
||||
|
||||
return [$project, $file];
|
||||
}
|
||||
|
||||
public function test_generation_is_inside_the_serializing_database_transaction(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
$scene = (new ThreeMfGeometryExtractor)->extract(Storage::disk('project_files')->path($file->storage_key));
|
||||
$level = DB::transactionLevel();
|
||||
$this->mock(ThreeMfGeometryExtractor::class)->shouldReceive('extract')->once()->andReturnUsing(function () use ($scene, $level): array {
|
||||
$this->assertGreaterThan($level, DB::transactionLevel());
|
||||
|
||||
return $scene;
|
||||
});
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->post(route('projects.files.render', [$project, $file]))->assertRedirect();
|
||||
}
|
||||
|
||||
public function test_failed_render_storage_does_not_create_a_render_record(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
$disk = \Mockery::mock(Storage::disk('project_renders'))->makePartial();
|
||||
$disk->shouldReceive('put')->once()->andReturnFalse();
|
||||
Storage::set('project_renders', $disk);
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->post(route('projects.files.render', [$project, $file]))->assertStatus(422);
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
}
|
||||
|
||||
public function test_audit_failure_rolls_back_the_record_and_cleans_up_generated_bytes(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
ProjectEvent::creating(function (): void {
|
||||
throw new \RuntimeException('Synthetic audit storage failure');
|
||||
});
|
||||
try {
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->post(route('projects.files.render', [$project, $file]))->assertStatus(422);
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
$this->assertSame([], Storage::disk('project_renders')->allFiles());
|
||||
} finally {
|
||||
ProjectEvent::flushEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_admin_can_generate_a_product_image_from_a_valid_3mf_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertRedirect(route('projects.show', $project));
|
||||
|
||||
$this->assertDatabaseCount('project_file_renders', 1);
|
||||
$render = ProjectFileRender::first();
|
||||
$this->assertSame($file->id, $render->project_file_id);
|
||||
$this->assertSame(1, $render->triangle_count);
|
||||
$this->assertSame(1, $render->instance_count);
|
||||
$this->assertStringContainsString('widget.3mf', $render->alt_text);
|
||||
Storage::disk('project_renders')->assertExists($render->storage_key);
|
||||
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'product_image_generated']);
|
||||
}
|
||||
|
||||
public function test_generating_does_not_modify_the_original_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$bytes = $this->validThreeMfBytes();
|
||||
[$project, $file] = $this->projectWith3mfFile($bytes);
|
||||
$before = Storage::disk('project_files')->get($file->storage_key);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]));
|
||||
|
||||
$after = Storage::disk('project_files')->get($file->storage_key);
|
||||
$this->assertSame($before, $after);
|
||||
$this->assertSame(hash('sha256', $bytes), hash('sha256', $after));
|
||||
}
|
||||
|
||||
public function test_calling_generate_twice_is_idempotent_and_creates_only_one_render(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))->assertRedirect();
|
||||
$firstKey = ProjectFileRender::first()->storage_key;
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertRedirect(route('projects.show', $project))
|
||||
->assertSessionHas('status', 'Product image already generated for this file.');
|
||||
|
||||
$this->assertDatabaseCount('project_file_renders', 1);
|
||||
$this->assertSame($firstKey, ProjectFileRender::first()->storage_key);
|
||||
$this->assertDatabaseCount('project_events', 1);
|
||||
}
|
||||
|
||||
public function test_rejects_generation_for_a_non_3mf_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$revision = ProjectRevision::factory()->for($project)->create();
|
||||
$file = ProjectFile::factory()->for($revision, 'revision')->create(['extension' => 'stl']);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertSessionHasErrors('file');
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_a_malicious_archive_with_a_clear_error_and_no_render_created(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->maliciousThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertSessionHasErrors('file');
|
||||
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
$this->assertCount(0, Storage::disk('project_renders')->allFiles());
|
||||
}
|
||||
|
||||
public function test_guest_cannot_generate_preview_or_download(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->post(route('projects.files.render', [$project, $file]))->assertRedirect(route('login'));
|
||||
$this->get(route('projects.files.render.preview', [$project, $file]))->assertRedirect(route('login'));
|
||||
$this->get(route('projects.files.render.download', [$project, $file]))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_generate_preview_or_download(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$user = User::factory()->create();
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($user)->post(route('projects.files.render', [$project, $file]))->assertForbidden();
|
||||
$this->actingAs($user)->get(route('projects.files.render.preview', [$project, $file]))->assertForbidden();
|
||||
$this->actingAs($user)->get(route('projects.files.render.download', [$project, $file]))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_cannot_reach_a_file_through_a_different_projects_url(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$otherProject = Project::factory()->create();
|
||||
[, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$otherProject, $file]))->assertNotFound();
|
||||
$this->actingAs($admin)->get(route('projects.files.render.preview', [$otherProject, $file]))->assertNotFound();
|
||||
$this->actingAs($admin)->get(route('projects.files.render.download', [$otherProject, $file]))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_preview_and_download_404_before_a_render_exists(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.render.preview', [$project, $file]))->assertNotFound();
|
||||
$this->actingAs($admin)->get(route('projects.files.render.download', [$project, $file]))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_preview_returns_the_image_with_the_correct_content_type(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]));
|
||||
$render = ProjectFileRender::first();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.render.preview', [$project, $file]))
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', $render->mime_type);
|
||||
}
|
||||
|
||||
public function test_download_returns_an_attachment_named_after_the_original_file(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
Storage::fake('project_renders');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]));
|
||||
$render = ProjectFileRender::first();
|
||||
$extension = $render->mime_type === 'image/png' ? 'png' : 'svg';
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.files.render.download', [$project, $file]))
|
||||
->assertOk()
|
||||
->assertHeader('content-disposition', 'attachment; filename='.$file->original_filename.'.'.$extension);
|
||||
}
|
||||
|
||||
public function test_viewing_the_project_page_never_triggers_rendering(): void
|
||||
{
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk();
|
||||
|
||||
$this->assertDatabaseCount('project_file_renders', 0);
|
||||
}
|
||||
|
||||
public function test_missing_csrf_token_is_rejected_when_verification_actually_runs(): void
|
||||
{
|
||||
$this->app['env'] = 'local';
|
||||
Storage::fake('project_files');
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
||||
->assertStatus(419);
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,36 @@ class ProjectListingLinkTest extends TestCase
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_a_listing_that_is_inactive_rather_than_live_or_draft(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/777*' => Http::response([
|
||||
'listing_id' => 777, 'shop_id' => 42, 'title' => 'Old item', 'state' => 'inactive',
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 777])
|
||||
->assertSessionHasErrors('listing_id');
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
|
||||
public function test_rejects_a_sold_out_listing(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/listings/778*' => Http::response([
|
||||
'listing_id' => 778, 'shop_id' => 42, 'title' => 'Sold out item', 'state' => 'sold_out',
|
||||
])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 778])
|
||||
->assertSessionHasErrors('listing_id');
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_attach_listings(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectListingOptionsTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private function connectEtsy(): void
|
||||
{
|
||||
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']);
|
||||
EtsyConnection::create([
|
||||
'shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => 'token', 'refresh_token' => 'refresh',
|
||||
'expires_at' => now()->addHour(), 'scopes' => 'listings_r listings_w transactions_r transactions_w shops_r',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_dropdown_shows_live_and_draft_listings_together_in_optgroups(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
|
||||
->push(['count' => 1, 'results' => [['listing_id' => 101, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active']]])
|
||||
->push(['count' => 1, 'results' => [['listing_id' => 202, 'shop_id' => 42, 'title' => 'Draft figurine', 'state' => 'draft']]]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('<optgroup label="Live">', false)
|
||||
->assertSee('<optgroup label="Draft">', false)
|
||||
->assertSee('Dragon figurine (#101)', false)
|
||||
->assertSee('Draft figurine (#202)', false);
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
public function test_duplicate_listing_pages_never_masquerade_as_a_complete_dropdown(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
$first = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Item {$i}", 'state' => 'active'], range(1, 100));
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
|
||||
->push(['count' => 150, 'results' => $first])
|
||||
->push(['count' => 150, 'results' => array_slice($first, 50)])
|
||||
->push(['count' => 0, 'results' => []])]);
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->get(route('projects.show', Project::factory()->create()))->assertOk()
|
||||
->assertSee('temporarily unavailable or incomplete')->assertDontSee('<optgroup', false);
|
||||
}
|
||||
|
||||
public function test_fetches_every_page_of_a_state_with_over_one_hundred_listings(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
$page1 = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Active Item {$i}", 'state' => 'active'], range(1, 100));
|
||||
$page2 = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Active Item {$i}", 'state' => 'active'], range(101, 150));
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::sequence()
|
||||
->push(['count' => 150, 'results' => $page1])
|
||||
->push(['count' => 150, 'results' => $page2])
|
||||
->push(['count' => 0, 'results' => []]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('Active Item 150 (#150)', false);
|
||||
Http::assertSentCount(3);
|
||||
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 0 && str_contains($request->url(), '/listings'));
|
||||
Http::assertSent(fn ($request) => ($request['offset'] ?? null) === 100 && str_contains($request->url(), '/listings'));
|
||||
}
|
||||
|
||||
public function test_fails_explicitly_rather_than_silently_truncating_when_a_state_exceeds_the_page_bound(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
// Every page claims 5000 total but only ever returns another 100 — never converges within the bound.
|
||||
$hugePage = array_map(fn (int $i) => ['listing_id' => $i, 'shop_id' => 42, 'title' => "Item {$i}", 'state' => 'active'], range(1, 100));
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 5000, 'results' => $hugePage])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('temporarily unavailable or incomplete')
|
||||
->assertDontSee('<optgroup', false);
|
||||
// 20 pages fetched (the bound) before failing explicitly — never an unbounded fetch loop.
|
||||
Http::assertSentCount(20);
|
||||
}
|
||||
|
||||
public function test_fails_explicitly_when_a_page_comes_back_short_of_the_reported_total(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 200, 'results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('temporarily unavailable or incomplete')
|
||||
->assertDontSee('<optgroup', false);
|
||||
}
|
||||
|
||||
public function test_zero_results_in_both_states_is_shown_as_an_explicit_empty_state_not_an_error(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 0, 'results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('No live or draft listings found')
|
||||
->assertDontSee('temporarily unavailable');
|
||||
}
|
||||
|
||||
public function test_no_etsy_connection_shows_an_explicit_message_instead_of_an_empty_dropdown(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()
|
||||
->assertSee('Connect the Etsy shop');
|
||||
}
|
||||
|
||||
public function test_show_page_never_makes_a_real_network_call(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake(['https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 0, 'results' => []])]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* The dropdown's rendered options are just a convenience; the server
|
||||
* must never trust that a submitted listing_id was actually one of
|
||||
* them. A tampered id the dropdown never offered still goes through the
|
||||
* same live ownership/state re-verification as any other submission.
|
||||
*/
|
||||
public function test_submitting_a_listing_id_absent_from_the_rendered_dropdown_is_still_independently_verified(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
$this->connectEtsy();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 1, 'results' => [
|
||||
['listing_id' => 101, 'shop_id' => 42, 'title' => 'Dragon figurine', 'state' => 'active'],
|
||||
]]),
|
||||
'https://api.etsy.com/v3/application/listings/999*' => Http::response([
|
||||
'listing_id' => 999, 'shop_id' => 999999, 'title' => 'Not ours', 'state' => 'active',
|
||||
]),
|
||||
]);
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$project = Project::factory()->create();
|
||||
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk()->assertDontSee('#999', false);
|
||||
|
||||
$this->actingAs($admin)->post(route('projects.listings.store', $project), ['listing_id' => 999])
|
||||
->assertSessionHasErrors('listing_id');
|
||||
$this->assertDatabaseCount('project_listing_links', 0);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,47 @@ class ProjectReleaseTest extends TestCase
|
||||
$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']);
|
||||
|
||||
Reference in New Issue
Block a user