Add project listing selector, confirmed revision and private 3MF renders

This commit is contained in:
2026-11-08 18:49:32 +00:00
parent e62b6800a6
commit 4e675510fd
29 changed files with 3394 additions and 14 deletions
@@ -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);
}
}
+309
View File
@@ -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);
}
}
+30
View File
@@ -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();
+170
View File
@@ -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);
}
}
+41
View File
@@ -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']);
+104
View File
@@ -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);
}
}
+429
View File
@@ -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);
}
}
}
+77
View File
@@ -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));
}
}