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']);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ProductImageRenderer;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductImageRendererTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return list<array{v: array{0:array{0:float,1:float,2:float},1:array{0:float,1:float,2:float},2:array{0:float,1:float,2:float}}, color: array{0:int,1:int,2:int,3:int}}>
|
||||
*/
|
||||
private function oneRedTriangle(): array
|
||||
{
|
||||
return [[
|
||||
'v' => [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]],
|
||||
'color' => [204, 0, 0, 255],
|
||||
]];
|
||||
}
|
||||
|
||||
public function test_renders_a_real_png_when_gd_is_available(): void
|
||||
{
|
||||
$renderer = new ProductImageRenderer(gdAvailable: true);
|
||||
|
||||
$result = $renderer->render($this->oneRedTriangle());
|
||||
|
||||
$this->assertSame('image/png', $result['mime']);
|
||||
$this->assertSame('gd', $result['renderer']);
|
||||
$this->assertSame(512, $result['width']);
|
||||
$this->assertSame(512, $result['height']);
|
||||
$this->assertStringStartsWith("\x89PNG\x0d\x0a\x1a\x0a", $result['bytes']);
|
||||
|
||||
// Decode it back and confirm an actual non-background pixel exists where the
|
||||
// triangle should project — this is a real rasterisation, not a blank canvas.
|
||||
$image = imagecreatefromstring($result['bytes']);
|
||||
$this->assertNotFalse($image);
|
||||
$backgroundRgb = imagecolorat($image, 5, 5);
|
||||
// The projected triangle's centroid, computed from its known world
|
||||
// coordinates and the corrected (non-flipped) isometric projection:
|
||||
// vertices land at pixels (256,32), (480,161), (32,161).
|
||||
$insideTriangleRgb = imagecolorat($image, 256, 120);
|
||||
$this->assertNotSame($backgroundRgb, $insideTriangleRgb);
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_a_triangle_facing_away_from_the_camera_is_culled_and_never_drawn(): void
|
||||
{
|
||||
// Same triangle as oneRedTriangle() but with v1/v2 swapped, flipping
|
||||
// its winding so the normal points -Z (away from the (1,1,1) camera).
|
||||
$backFacing = [[
|
||||
'v' => [[0.0, 0.0, 0.0], [0.0, 10.0, 0.0], [10.0, 0.0, 0.0]],
|
||||
'color' => [204, 0, 0, 255],
|
||||
]];
|
||||
$renderer = new ProductImageRenderer(gdAvailable: true);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/faces away from the camera/i');
|
||||
$renderer->render($backFacing);
|
||||
}
|
||||
|
||||
public function test_falls_back_to_fixed_content_numeric_only_svg_when_gd_is_unavailable(): void
|
||||
{
|
||||
$renderer = new ProductImageRenderer(gdAvailable: false);
|
||||
|
||||
$result = $renderer->render($this->oneRedTriangle());
|
||||
|
||||
$this->assertSame('image/svg+xml', $result['mime']);
|
||||
$this->assertSame('svg', $result['renderer']);
|
||||
$this->assertStringContainsString('<svg', $result['bytes']);
|
||||
// Shaded from the pure red base colour (204,0,0) by this face's normal
|
||||
// angle to the fixed light direction, not the unshaded base colour.
|
||||
$this->assertStringContainsString('fill="#bb0000"', $result['bytes']);
|
||||
$this->assertStringNotContainsString('<script', $result['bytes']);
|
||||
$this->assertStringNotContainsString('<image', $result['bytes']);
|
||||
$this->assertStringNotContainsString('xlink:href', $result['bytes']);
|
||||
$this->assertStringNotContainsString('url(', $result['bytes']);
|
||||
// Only numeric coordinates and hex colours appear inside each polygon tag.
|
||||
$this->assertMatchesRegularExpression('/<polygon points="[\d.,\s-]+" fill="#[0-9a-f]{6}"\/>/', $result['bytes']);
|
||||
}
|
||||
|
||||
public function test_rejects_an_empty_scene_rather_than_rendering_a_blank_image(): void
|
||||
{
|
||||
$renderer = new ProductImageRenderer(gdAvailable: true);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$renderer->render([]);
|
||||
}
|
||||
|
||||
public function test_rejects_translucent_material_instead_of_silently_rendering_it_opaque(): void
|
||||
{
|
||||
$triangles = $this->oneRedTriangle();
|
||||
$triangles[0]['color'][3] = 128;
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/transparen/i');
|
||||
(new ProductImageRenderer(gdAvailable: true))->render($triangles);
|
||||
}
|
||||
|
||||
public function test_renderer_name_reflects_gd_availability(): void
|
||||
{
|
||||
$this->assertSame('gd', (new ProductImageRenderer(gdAvailable: true))->rendererName());
|
||||
$this->assertSame('svg', (new ProductImageRenderer(gdAvailable: false))->rendererName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ProductImageRenderer;
|
||||
use App\Services\ThreeMfGeometryExtractor;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Full-pipeline regression (3MF bytes -> ThreeMfGeometryExtractor -> ProductImageRenderer
|
||||
* -> decoded PNG pixels) using a correctly-wound 20x20x20 cube with a distinct colour
|
||||
* on each of its 6 faces. This is the same cube used to generate the sample image for
|
||||
* visual inspection. Only the three faces whose outward normal points toward the
|
||||
* (1,1,1) camera (+Z "top", +X "right", +Y "back") should ever be visible; the other
|
||||
* three (-Z, -X, -Y) must be fully culled. This catches both the "upside down"
|
||||
* projection defect (an earlier version flipped Z) and the "no real hidden-surface
|
||||
* removal" defect (an earlier version only sorted by triangle centroid, which does not
|
||||
* guarantee a culled-looking, non-overlapping solid) with pixel-level assertions, not
|
||||
* just "the canvas isn't blank".
|
||||
*/
|
||||
class ProductImageRenderingPipelineTest extends TestCase
|
||||
{
|
||||
private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
||||
|
||||
// Base material indices, in <basematerials> declaration order.
|
||||
private const TOP = 0; // +Z, visible, red
|
||||
|
||||
private const RIGHT = 1; // +X, visible, green
|
||||
|
||||
private const BACK = 2; // +Y, visible, blue
|
||||
|
||||
private const BOTTOM = 3; // -Z, culled, yellow
|
||||
|
||||
private const LEFT = 4; // -X, culled, magenta
|
||||
|
||||
private const FRONT = 5; // -Y, culled, cyan
|
||||
|
||||
/**
|
||||
* A 20x20x20 cube with correct outward CCW winding (verified by hand per
|
||||
* the 3MF Core Specification's right-hand-rule convention) and one
|
||||
* base-material colour triangle-pinned per face.
|
||||
*/
|
||||
private function coloredCubeBytes(): string
|
||||
{
|
||||
$vertices = '<vertex x="0" y="0" z="0"/><vertex x="20" y="0" z="0"/><vertex x="20" y="20" z="0"/><vertex x="0" y="20" z="0"/>'
|
||||
.'<vertex x="0" y="0" z="20"/><vertex x="20" y="0" z="20"/><vertex x="20" y="20" z="20"/><vertex x="0" y="20" z="20"/>';
|
||||
|
||||
$tri = fn (int $v1, int $v2, int $v3, int $colorIndex): string => sprintf(
|
||||
'<triangle v1="%d" v2="%d" v3="%d" pid="1" p1="%d"/>', $v1, $v2, $v3, $colorIndex,
|
||||
);
|
||||
|
||||
$triangles = $tri(0, 3, 2, self::BOTTOM).$tri(0, 2, 1, self::BOTTOM)
|
||||
.$tri(4, 5, 6, self::TOP).$tri(4, 6, 7, self::TOP)
|
||||
.$tri(1, 2, 6, self::RIGHT).$tri(1, 6, 5, self::RIGHT)
|
||||
.$tri(0, 4, 7, self::LEFT).$tri(0, 7, 3, self::LEFT)
|
||||
.$tri(0, 1, 5, self::FRONT).$tri(0, 5, 4, self::FRONT)
|
||||
.$tri(2, 7, 6, self::BACK).$tri(2, 3, 7, self::BACK);
|
||||
|
||||
$modelXml = '<?xml version="1.0"?><model xmlns="'.self::NS.'"><resources>'
|
||||
.'<basematerials id="1">'
|
||||
.'<base name="top-red" displaycolor="#FF0000"/>'
|
||||
.'<base name="right-green" displaycolor="#00FF00"/>'
|
||||
.'<base name="back-blue" displaycolor="#0000FF"/>'
|
||||
.'<base name="bottom-yellow" displaycolor="#FFFF00"/>'
|
||||
.'<base name="left-magenta" displaycolor="#FF00FF"/>'
|
||||
.'<base name="front-cyan" displaycolor="#00FFFF"/>'
|
||||
.'</basematerials>'
|
||||
."<object id=\"1\" type=\"model\"><mesh><vertices>{$vertices}</vertices><triangles>{$triangles}</triangles></mesh></object>"
|
||||
.'</resources><build><item objectid="1"/></build></model>';
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'cube').'.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', $modelXml);
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \GdImage
|
||||
*/
|
||||
private function renderCube()
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'cube').'.3mf';
|
||||
file_put_contents($path, $this->coloredCubeBytes());
|
||||
$scene = (new ThreeMfGeometryExtractor)->extract($path);
|
||||
unlink($path);
|
||||
|
||||
$rendered = (new ProductImageRenderer(gdAvailable: true))->render($scene['triangles']);
|
||||
$this->assertSame(12, $scene['triangle_count']);
|
||||
|
||||
$image = imagecreatefromstring($rendered['bytes']);
|
||||
$this->assertNotFalse($image);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{red: int, green: int, blue: int}
|
||||
*/
|
||||
private function rgbAt(\GdImage $image, int $x, int $y): array
|
||||
{
|
||||
return imagecolorsforindex($image, imagecolorat($image, $x, $y));
|
||||
}
|
||||
|
||||
public function test_top_face_is_visible_at_the_top_of_the_image_with_its_own_colour(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
// Face centroid in screen space, derived from the cube's known world
|
||||
// coordinates and the corrected (non-flipped) isometric projection.
|
||||
$top = $this->rgbAt($image, 226, 144);
|
||||
|
||||
$this->assertGreaterThan(100, $top['red'], 'Top face should be red-shaded.');
|
||||
$this->assertLessThan(50, $top['green']);
|
||||
$this->assertLessThan(50, $top['blue']);
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_right_face_is_visible_with_its_own_colour(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
$right = $this->rgbAt($image, 323, 312);
|
||||
|
||||
$this->assertLessThan(50, $right['red']);
|
||||
$this->assertGreaterThan(100, $right['green'], 'Right face should be green-shaded.');
|
||||
$this->assertLessThan(50, $right['blue']);
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_back_face_is_visible_with_its_own_colour(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
$back = $this->rgbAt($image, 129, 312);
|
||||
|
||||
$this->assertLessThan(50, $back['red']);
|
||||
$this->assertLessThan(50, $back['green']);
|
||||
$this->assertGreaterThan(30, $back['blue'], 'Back face should be blue-shaded.');
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_the_three_hidden_interior_faces_are_never_visible_anywhere_in_the_image(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
// Yellow (bottom), magenta (left) and cyan (front) each have two
|
||||
// channels simultaneously high and one low; the dark-navy background
|
||||
// (15,23,42) and the three shaded visible faces (each with exactly
|
||||
// one dominant channel) never match that signature, so any match
|
||||
// here proves a culled face leaked through.
|
||||
for ($x = 0; $x < ProductImageRenderer::CANVAS_SIZE; $x += 4) {
|
||||
for ($y = 0; $y < ProductImageRenderer::CANVAS_SIZE; $y += 4) {
|
||||
$rgb = $this->rgbAt($image, $x, $y);
|
||||
$isYellowLike = $rgb['red'] > 100 && $rgb['green'] > 100 && $rgb['blue'] < 50;
|
||||
$isMagentaLike = $rgb['red'] > 100 && $rgb['blue'] > 100 && $rgb['green'] < 50;
|
||||
$isCyanLike = $rgb['green'] > 100 && $rgb['blue'] > 100 && $rgb['red'] < 50;
|
||||
$this->assertFalse($isYellowLike, "Bottom (culled) face leaked at ({$x},{$y}).");
|
||||
$this->assertFalse($isMagentaLike, "Left (culled) face leaked at ({$x},{$y}).");
|
||||
$this->assertFalse($isCyanLike, "Front (culled) face leaked at ({$x},{$y}).");
|
||||
}
|
||||
}
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
public function test_background_corners_remain_background(): void
|
||||
{
|
||||
$image = $this->renderCube();
|
||||
|
||||
$corner = $this->rgbAt($image, 5, 5);
|
||||
|
||||
$this->assertSame(15, $corner['red']);
|
||||
$this->assertSame(23, $corner['green']);
|
||||
$this->assertSame(42, $corner['blue']);
|
||||
imagedestroy($image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ThreeMfGeometryExtractor;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ThreeMfGeometryExtractorTest extends TestCase
|
||||
{
|
||||
private ThreeMfGeometryExtractor $extractor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->extractor = new ThreeMfGeometryExtractor;
|
||||
}
|
||||
|
||||
private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
||||
|
||||
private function model(string $resources, string $build): string
|
||||
{
|
||||
return '<?xml version="1.0"?><model xmlns="'.self::NS.'"><resources>'.$resources.'</resources><build>'.$build.'</build></model>';
|
||||
}
|
||||
|
||||
private function singleTriangleObject(int $id = 1, string $attrs = ''): string
|
||||
{
|
||||
return '<object id="'.$id.'" type="model"'.$attrs.'>
|
||||
<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>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function make3mf(array $entries): string
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
foreach ($entries as $name => $contents) {
|
||||
$zip->addFromString($name, $contents);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function validArchive(string $modelXml): string
|
||||
{
|
||||
return $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => $modelXml,
|
||||
'_rels/.rels' => '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_mirrored_build_transform_preserves_outward_triangle_winding(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), '<item objectid="1" transform="-1 0 0 0 1 0 0 0 1 0 0 0"/>'));
|
||||
try {
|
||||
$scene = $this->extractor->extract($path);
|
||||
[$a, $b, $c] = $scene['triangles'][0]['v'];
|
||||
$normalZ = ($b[0] - $a[0]) * ($c[1] - $a[1]) - ($b[1] - $a[1]) * ($c[0] - $a[0]);
|
||||
$this->assertGreaterThan(0, $normalZ, 'Mirroring must preserve the outward +Z surface.');
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_exhausted_parse_budget_is_enforced_before_expanding_build_items(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), ''));
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/time budget/i');
|
||||
(new ThreeMfGeometryExtractor(timeBudgetSeconds: -1.0))->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_extracts_a_single_triangle_placed_by_one_build_item(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), '<item objectid="1"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame(1, $scene['triangle_count']);
|
||||
$this->assertSame(1, $scene['instance_count']);
|
||||
$this->assertSame([148, 163, 184, 255], $scene['triangles'][0]['color']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_resolves_object_level_base_material_color(): void
|
||||
{
|
||||
$resources = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/></basematerials>'
|
||||
.$this->singleTriangleObject(1, ' pid="2" pindex="0"');
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame([204, 0, 0, 255], $scene['triangles'][0]['color']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_triangle_level_pid_overrides_the_objects_default_color(): void
|
||||
{
|
||||
$resources = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/><base name="Blue" displaycolor="#0000CC"/></basematerials>
|
||||
<object id="1" type="model" pid="2" pindex="0">
|
||||
<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" pid="2" p1="1"/></triangles>
|
||||
</mesh>
|
||||
</object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame([0, 0, 204, 255], $scene['triangles'][0]['color']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_resolves_object_level_color_from_a_colorgroup(): void
|
||||
{
|
||||
$resources = '<colorgroup id="5"><color color="#00CC33"/></colorgroup>'
|
||||
.$this->singleTriangleObject(1, ' pid="5" pindex="0"');
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame([0, 204, 51, 255], $scene['triangles'][0]['color']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_a_pid_referencing_an_unknown_property_group_rather_than_defaulting_to_grey(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(1, ' pid="999" pindex="0"'), '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_pindex_that_does_not_exist_within_a_known_property_group(): void
|
||||
{
|
||||
$resources = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/></basematerials>'
|
||||
.$this->singleTriangleObject(1, ' pid="2" pindex="7"');
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_triangle_with_interpolated_per_vertex_colors(): void
|
||||
{
|
||||
$resources = '<basematerials id="2"><base name="Red" displaycolor="#CC0000"/><base name="Blue" displaycolor="#0000CC"/></basematerials>
|
||||
<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" pid="2" p1="0" p2="1" p3="0"/></triangles>
|
||||
</mesh>
|
||||
</object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/interpolat/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_resolves_nested_components_composing_transforms(): void
|
||||
{
|
||||
$resources = $this->singleTriangleObject(1).
|
||||
'<object id="2" type="model"><components><component objectid="1" transform="1 0 0 0 1 0 0 0 1 100 0 0"/></components></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="2" transform="1 0 0 0 1 0 0 0 1 0 5 0"/>'));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame(1, $scene['triangle_count']);
|
||||
// World vertex 0 (local 0,0,0) shifted by the component's +100 X then the item's +5 Y.
|
||||
$this->assertSame([100.0, 5.0, 0.0], $scene['triangles'][0]['v'][0]);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_multiple_build_items_count_as_multiple_instances(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model(
|
||||
$this->singleTriangleObject(),
|
||||
'<item objectid="1"/><item objectid="1" transform="1 0 0 0 1 0 0 0 1 50 0 0"/>',
|
||||
));
|
||||
|
||||
$scene = $this->extractor->extract($path);
|
||||
|
||||
$this->assertSame(2, $scene['triangle_count']);
|
||||
$this->assertSame(2, $scene['instance_count']);
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
public function test_rejects_texture_resources_as_unsupported(): void
|
||||
{
|
||||
$resources = '<texture2d id="9" path="/textures/skin.png" contenttype="image/png"/>'.$this->singleTriangleObject();
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/texture/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_component_referencing_an_external_model_part(): void
|
||||
{
|
||||
$resources = $this->singleTriangleObject(1).
|
||||
'<object id="2" type="model"><components><component objectid="1" path="/other.model"/></components></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="2"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/external/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_build_item_referencing_an_external_model_part(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), '<item objectid="1" path="/other.model"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/external/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_circular_component_reference(): void
|
||||
{
|
||||
$resources = '<object id="1" type="model"><components><component objectid="2"/></components></object>'
|
||||
.'<object id="2" type="model"><components><component objectid="1"/></components></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/circular/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_build_item_referencing_a_nonexistent_object(): void
|
||||
{
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), '<item objectid="999"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_an_empty_build_plate_as_no_renderable_geometry(): void
|
||||
{
|
||||
$resources = '<object id="1" type="model"></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/no renderable geometry/i');
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_too_many_build_items(): void
|
||||
{
|
||||
$items = str_repeat('<item objectid="1"/>', 201);
|
||||
$path = $this->validArchive($this->model($this->singleTriangleObject(), $items));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_a_triangle_referencing_a_vertex_index_that_does_not_exist(): void
|
||||
{
|
||||
$resources = '<object id="1" type="model"><mesh><vertices><vertex x="0" y="0" z="0"/></vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles></mesh></object>';
|
||||
$path = $this->validArchive($this->model($resources, '<item objectid="1"/>'));
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_missing_required_model_part(): void
|
||||
{
|
||||
$path = $this->make3mf(['[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>']);
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_path_traversal_entry(): void
|
||||
{
|
||||
$path = $this->make3mf([
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => $this->model($this->singleTriangleObject(), '<item objectid="1"/>'),
|
||||
'../../../etc/evil.txt' => 'pwned',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_entry_with_extreme_compression_ratio(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '<item objectid="1"/>'));
|
||||
$zip->addFromString('bomb.bin', str_repeat("\0", 20 * 1024 * 1024));
|
||||
$zip->setCompressionName('bomb.bin', ZipArchive::CM_DEFLATE, 9);
|
||||
$zip->close();
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_too_many_entries(): void
|
||||
{
|
||||
$entries = [
|
||||
'[Content_Types].xml' => '<?xml version="1.0"?><Types xmlns="x"/>',
|
||||
'3D/3dmodel.model' => $this->model($this->singleTriangleObject(), '<item objectid="1"/>'),
|
||||
];
|
||||
for ($i = 0; $i < 2500; $i++) {
|
||||
$entries["junk/{$i}.txt"] = 'x';
|
||||
}
|
||||
$path = $this->make3mf($entries);
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_model_part_with_doctype_declaration(): void
|
||||
{
|
||||
$modelXml = '<?xml version="1.0"?><!DOCTYPE model [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><model xmlns="'.self::NS.'">&xxe;</model>';
|
||||
$path = $this->validArchive($modelXml);
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_rejects_3mf_with_a_symlink_entry(): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
||||
$zip->addFromString('3D/3dmodel.model', $this->model($this->singleTriangleObject(), '<item objectid="1"/>'));
|
||||
$zip->addFromString('evil-link', '/etc/passwd');
|
||||
$zip->setExternalAttributesName('evil-link', ZipArchive::OPSYS_UNIX, 0120777 << 16);
|
||||
$zip->close();
|
||||
|
||||
try {
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->extractor->extract($path);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\ThreeMfTransform;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ThreeMfTransformTest extends TestCase
|
||||
{
|
||||
public function test_identity_leaves_a_point_unchanged(): void
|
||||
{
|
||||
$this->assertSame([1.0, 2.0, 3.0], ThreeMfTransform::apply(ThreeMfTransform::identity(), [1.0, 2.0, 3.0]));
|
||||
}
|
||||
|
||||
public function test_parses_twelve_space_separated_floats(): void
|
||||
{
|
||||
$matrix = ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 10 20 30');
|
||||
|
||||
$this->assertSame([10.0, 20.0, 33.0], ThreeMfTransform::apply($matrix, [0.0, 0.0, 3.0]));
|
||||
}
|
||||
|
||||
public function test_rejects_a_transform_without_exactly_twelve_values(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1');
|
||||
}
|
||||
|
||||
public function test_rejects_a_non_numeric_transform_value(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 x 0 0');
|
||||
}
|
||||
|
||||
public function test_rejects_a_non_finite_transform_value(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 NAN 0 0');
|
||||
}
|
||||
|
||||
public function test_translation_moves_a_point(): void
|
||||
{
|
||||
$translate = ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 5 6 7');
|
||||
|
||||
$this->assertSame([6.0, 8.0, 10.0], ThreeMfTransform::apply($translate, [1.0, 2.0, 3.0]));
|
||||
}
|
||||
|
||||
public function test_scale_multiplies_each_axis(): void
|
||||
{
|
||||
$scale = ThreeMfTransform::parse('2 0 0 0 3 0 0 0 4 0 0 0');
|
||||
|
||||
$this->assertSame([2.0, 6.0, 12.0], ThreeMfTransform::apply($scale, [1.0, 2.0, 3.0]));
|
||||
}
|
||||
|
||||
public function test_compose_applies_inner_transform_before_outer(): void
|
||||
{
|
||||
$translateThenScale = ThreeMfTransform::compose(
|
||||
outer: ThreeMfTransform::parse('2 0 0 0 2 0 0 0 2 0 0 0'),
|
||||
inner: ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 10 0 0'),
|
||||
);
|
||||
|
||||
// Expected: apply(outer, apply(inner, p)) = apply(outer, [11,0,0]) = [22,0,0]
|
||||
$this->assertSame([22.0, 0.0, 0.0], ThreeMfTransform::apply($translateThenScale, [1.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
public function test_compose_matches_applying_each_transform_in_turn_for_an_arbitrary_rotation_like_matrix(): void
|
||||
{
|
||||
$outer = ThreeMfTransform::parse('0 1 0 -1 0 0 0 0 1 3 4 5');
|
||||
$inner = ThreeMfTransform::parse('1 0 0 0 1 0 0 0 1 1 2 3');
|
||||
$point = [7.0, -2.0, 0.5];
|
||||
|
||||
$composed = ThreeMfTransform::compose($outer, $inner);
|
||||
|
||||
$expected = ThreeMfTransform::apply($outer, ThreeMfTransform::apply($inner, $point));
|
||||
$this->assertSame($expected, ThreeMfTransform::apply($composed, $point));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user