Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s

Replace the old touchmark/PNG identity with the supplied lockup and
monogram, segmented role-aware navigation, Live/Draft listing filters
with generation-based cache invalidation, and admin Projects covering
immutable file revisions, listing live pointers, and exact-decimal
filament/packaging/shipping costing. Additive schema only; no Etsy
publication. Isolated clone tests: 301 tests, 1162 assertions.
This commit is contained in:
2026-11-08 12:50:56 +00:00
parent c8c5822166
commit d4928789c4
83 changed files with 5808 additions and 84 deletions
+233
View File
@@ -0,0 +1,233 @@
<?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 ProjectRevisionFileTest extends TestCase
{
use LazilyRefreshDatabase;
private function stl(string $name = 'part.stl'): UploadedFile
{
$content = "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";
return UploadedFile::fake()->createWithContent($name, $content);
}
public function test_admin_can_upload_a_new_revision_with_files(): void
{
Storage::fake('project_files');
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$response = $this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'change_notes' => 'First cut',
'files' => [$this->stl('body.stl')],
'roles' => ['body'],
]);
$response->assertRedirect();
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 1, 'status' => 'draft']);
$revision = ProjectRevision::first();
$file = ProjectFile::where('revision_id', $revision->id)->first();
$this->assertNotNull($file);
$this->assertSame('body.stl', $file->original_filename);
$this->assertNotSame('body.stl', $file->storage_key);
Storage::disk('project_files')->assertExists($file->storage_key);
}
public function test_revision_numbers_increment_per_project(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
Storage::fake('project_files');
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'files' => [$this->stl()], 'roles' => ['body'],
]);
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'files' => [$this->stl()], 'roles' => ['body'],
]);
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 1]);
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 2]);
}
public function test_rejects_upload_exceeding_file_count_limit(): void
{
Storage::fake('project_files');
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$files = [];
$roles = [];
for ($i = 0; $i < 11; $i++) {
$files[] = $this->stl("part{$i}.stl");
$roles[] = 'body';
}
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'files' => $files, 'roles' => $roles,
])->assertSessionHasErrors('files');
}
public function test_rejects_malicious_zip_disguised_as_3mf(): void
{
Storage::fake('project_files');
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$path = tempnam(sys_get_temp_dir(), 'zip');
file_put_contents($path, random_bytes(64));
$file = new UploadedFile($path, 'evil.3mf', null, null, true);
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'files' => [$file], 'roles' => ['body'],
])->assertSessionHasErrors('files.0');
$this->assertDatabaseCount('project_files', 0);
unlink($path);
}
public function test_failed_validation_leaves_no_orphaned_stored_bytes(): void
{
Storage::fake('project_files');
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$path = tempnam(sys_get_temp_dir(), 'zip');
file_put_contents($path, random_bytes(64));
$badFile = new UploadedFile($path, 'evil.3mf', null, null, true);
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'files' => [$this->stl('good.stl'), $badFile], 'roles' => ['body', 'body'],
]);
$this->assertDatabaseCount('project_files', 0);
$this->assertCount(0, Storage::disk('project_files')->allFiles());
unlink($path);
}
public function test_non_validation_failure_mid_upload_cleans_up_stored_bytes(): void
{
Storage::fake('project_files');
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$calls = 0;
ProjectFile::creating(function () use (&$calls) {
$calls++;
if ($calls === 2) {
throw new \RuntimeException('Simulated DB failure mid-upload.');
}
});
try {
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'files' => [$this->stl('a.stl'), $this->stl('b.stl')],
'roles' => ['body', 'body'],
]);
} finally {
ProjectFile::flushEventListeners();
}
$this->assertDatabaseCount('project_files', 0);
$this->assertCount(0, Storage::disk('project_files')->allFiles());
}
public function test_rejects_a_batch_whose_combined_size_is_excessive(): void
{
Storage::fake('project_files');
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$files = [];
$roles = [];
for ($i = 0; $i < 4; $i++) {
$files[] = UploadedFile::fake()->create("big{$i}.stl", 90 * 1024, 'model/stl');
$roles[] = 'body';
}
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'files' => $files, 'roles' => $roles,
])->assertSessionHasErrors('files');
$this->assertDatabaseCount('project_files', 0);
}
public function test_approving_a_revision_locks_in_approver_and_timestamp(): void
{
Storage::fake('project_files');
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'files' => [$this->stl()], 'roles' => ['body'],
]);
$revision = ProjectRevision::first();
$this->actingAs($admin)->post(route('projects.revisions.approve', [$project, $revision]))->assertRedirect();
$revision->refresh();
$this->assertSame('approved', $revision->status);
$this->assertSame($admin->id, $revision->approved_by);
$this->assertNotNull($revision->approved_at);
}
public function test_approved_revision_cannot_be_modified_by_adding_more_files(): void
{
Storage::fake('project_files');
$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.store', $project), [
'files' => [$this->stl()], 'roles' => ['body'], 'revision_id' => $revision->id,
]);
// A correction always creates a new revision rather than mutating the approved one.
$this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 2]);
$this->assertSame(0, ProjectFile::where('revision_id', $revision->id)->count());
}
public function test_guest_cannot_download_project_file(): void
{
Storage::fake('project_files');
$project = Project::factory()->create();
$revision = ProjectRevision::factory()->for($project)->create();
$file = ProjectFile::factory()->for($revision, 'revision')->create();
$this->get(route('projects.files.download', [$project, $file]))->assertRedirect(route('login'));
}
public function test_cannot_download_a_file_through_a_different_projects_url(): void
{
Storage::fake('project_files');
$admin = User::factory()->create(['role' => 'admin']);
$projectA = Project::factory()->create();
$projectB = Project::factory()->create();
$revision = ProjectRevision::factory()->for($projectA)->create();
$file = ProjectFile::factory()->for($revision, 'revision')->create();
$this->actingAs($admin)->get(route('projects.files.download', [$projectB, $file]))->assertNotFound();
}
public function test_admin_can_download_an_owned_project_file(): void
{
Storage::fake('project_files');
$admin = User::factory()->create(['role' => 'admin']);
$project = Project::factory()->create();
$this->actingAs($admin)->post(route('projects.revisions.store', $project), [
'files' => [$this->stl('mypart.stl')], 'roles' => ['body'],
]);
$file = ProjectFile::first();
$this->actingAs($admin)->get(route('projects.files.download', [$project, $file]))
->assertOk()
->assertHeader('content-disposition', 'attachment; filename=mypart.stl');
}
}