database = tempnam(sys_get_temp_dir(), 'meshvault-pipeline-'); $this->pdo = RepositoryDatabase::migrate($this->database); $this->jobs = new PdoImportJobRepository($this->pdo); $this->catalog = new PdoCatalogRepository($this->pdo); $this->library = RepositoryDatabase::directory('library'); $this->source = RepositoryDatabase::directory('source'); } protected function tearDown(): void { unset($this->pdo, $this->jobs, $this->catalog); RepositoryDatabase::cleanup($this->database); if (isset($this->library)) { RepositoryDatabase::remove($this->library); } if (isset($this->source)) { RepositoryDatabase::remove($this->source); } } private function service(?SpaceChecker $space = null): ImportService { $safety = new PathSafety($this->source); return new ImportService( new FileEnumerator(new LocalFilesystem(), $safety), $this->jobs, new \MeshVault\Indexer\LegacyGuessedImportGroupingAdapter($this->catalog), $space ?? new SpaceChecker(), $this->library, ); } private function worker(?callable $shouldStop = null, array $operations = []): ImportWorker { $safety = new PathSafety($this->library); $sourceSafety = new PathSafety($this->source); return new ImportWorker( $this->jobs, new \MeshVault\Catalog\CatalogService($this->catalog), new StreamingHasher($sourceSafety), new VerifiedCopier($safety, $operations), new StlValidator($sourceSafety), $safety, new SpaceChecker(), $this->library, new NullLogger(), $shouldStop, ); } private function writeStl(string $relative, string $variant = 'default'): string { $path = $this->source . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative); if (!is_dir(dirname($path))) { mkdir(dirname($path), 0777, true); } $facet = "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n"; $content = "solid test_$variant\n" . $facet . "endsolid test_$variant\n"; file_put_contents($path, $content); return $path; } public function testNullOwnerIsRegisteredAndScanFailuresAreNot(): void { $source = $this->writeStl('loose.stl'); $job = $this->jobs->create($this->source, [ ['source_path' => $source, 'size' => filesize($source), 'model_id' => null], ['source_path' => 'unreadable', 'size' => 0, 'model_id' => null, 'status' => 'Failed', 'failure_reason' => 'SCAN_READ_ERROR'], ]); $this->worker()->runOnce(); $files = $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC); self::assertCount(1, $files); self::assertNull($files[0]['model_id']); self::assertSame('Completed', $this->jobs->items($job)[0]['status']); } public function testRegistrationConflictReportsCatalogConflictAndPreservesBytes(): void { $source = $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $other = $this->catalog->ensureModel('/other', 'other'); $id = $this->catalog->recordFile($other, 'planes/wing.stl', hash_file('sha256', $source), filesize($source)); $sourceBefore = PhysicalFileSnapshot::capture($this->source); $this->worker()->runOnce(); $item = $this->jobs->items($job)[0]; self::assertSame('Failed', $item['status']); self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']); self::assertSame($other, (int) $this->catalog->file($id)['model_id']); self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source)); self::assertFileDoesNotExist($this->library . '/planes/wing.stl'); } public function testCompletionRegistersBeforeUpdatingItemPath(): void { if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') { self::markTestSkipped('SQLite ordering assertion trigger.'); } $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $this->pdo->exec("CREATE TRIGGER catalog_first BEFORE UPDATE OF managed_path ON import_items" . " WHEN OLD.hash IS NOT NULL AND NOT EXISTS (SELECT 1 FROM files WHERE managed_path = NEW.managed_path)" . " BEGIN SELECT RAISE(ABORT, 'catalog must precede progress'); END"); $this->worker()->runOnce(); self::assertSame('Completed', $this->jobs->items($job)[0]['status']); } public function testMissingNonNullOwnerFailsRatherThanBecomingUnassigned(): void { if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') { self::markTestSkipped('SQLite fixture deliberately simulates a stale manifest owner.'); } $source = $this->writeStl('loose.stl'); $this->pdo->exec('PRAGMA foreign_keys = OFF'); $job = $this->jobs->create($this->source, [ ['source_path' => $source, 'size' => filesize($source), 'model_id' => 999999], ]); $this->pdo->exec('PRAGMA foreign_keys = ON'); $sourceBefore = PhysicalFileSnapshot::capture($this->source); $this->worker()->runOnce(); $item = $this->jobs->items($job)[0]; self::assertSame('Failed', $item['status']); self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']); self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn()); self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source)); self::assertFileDoesNotExist($this->library . '/unfiled/loose.stl'); } public static function invalidExplicitOwners(): array { return [ 'deleted before execution' => [true, false, false], 'deleted before recovery' => [true, true, false], 'different manifest owner' => [false, false, true], 'missing historical owner with replacement' => [true, false, true], ]; } #[\PHPUnit\Framework\Attributes\DataProvider('invalidExplicitOwners')] public function testExplicitOwnershipFailsClosed(bool $delete, bool $recover, bool $replace): void { $source = $this->writeStl('part.stl'); $adapter = new \MeshVault\Indexer\ExplicitImportGroupingAdapter($this->catalog, $this->jobs); $service = new ImportService( new FileEnumerator(new LocalFilesystem(), new PathSafety($this->source)), $this->jobs, $adapter, new SpaceChecker(), $this->library, ); $model = $this->catalog->createModel('/confirmed', 'Part'); $job = $service->import($this->source, ['stl'], [ '*' => ['confirmed' => true, 'model_id' => $model], ]); $item = $this->jobs->items($job)[0]; $destination = $this->library . '/confirmed/part.stl'; if ($recover) { $this->jobs->claimNext(); $this->jobs->planItem( (int) $item['id'], 'confirmed/part.stl', hash_file('sha256', $source), filesize($source) ); mkdir(dirname($destination)); copy($source, $destination); unlink($source); // Recovery must validate ownership even without the source. } if ($delete) { $this->pdo->exec('DELETE FROM models WHERE id = ' . $model); self::assertNull($this->jobs->items($job)[0]['model_id']); } if ($replace) { $other = $this->catalog->createModel('/replacement', 'Other'); $this->pdo->exec('UPDATE import_items SET model_id = ' . $other . ' WHERE id = ' . (int) $item['id']); } self::assertSame([$source => $model], $adapter->recoverDecisions($job)); $sourceBefore = PhysicalFileSnapshot::capture($this->source); $this->worker()->runOnce(); $item = $this->jobs->items($job)[0]; self::assertSame('Failed', $item['status']); self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']); self::assertSame('Failed', $this->jobs->get($job)['status']); self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn()); self::assertSame([$source => $model], $adapter->recoverDecisions($job)); self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source)); self::assertFileDoesNotExist($this->library . '/unfiled/part.stl'); self::assertFileDoesNotExist($this->library . '/replacement/part.stl'); if ($recover) { self::assertSame($item['hash'], hash_file('sha256', $destination)); self::assertSame('confirmed/part.stl', $item['managed_path']); } else { self::assertNull($item['managed_path']); self::assertFileDoesNotExist($destination); } } public function testCallerTransactionIsRejectedBeforePublication(): void { $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $this->pdo->beginTransaction(); try { $this->worker()->runOnce(); self::fail('Uncommitted publication intent accepted'); } catch (\LogicException $error) { self::assertStringContainsString('transaction', $error->getMessage()); } finally { self::assertTrue($this->pdo->inTransaction()); $this->pdo->rollBack(); } self::assertSame('Pending', $this->jobs->items($job)[0]['status']); self::assertFileDoesNotExist($this->library . '/planes/wing.stl'); } public function testCompletionSqlFailureCompensatesOwnedSuffix(): void { if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') { self::markTestSkipped('SQLite failure-injection trigger.'); } $source = $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); mkdir($this->library . '/planes'); file_put_contents($this->library . '/planes/wing.stl', 'different bytes'); $sourceBefore = PhysicalFileSnapshot::capture($this->source); $libraryBefore = PhysicalFileSnapshot::capture($this->library); // Fail after registration and item-path update, during progress refresh. $this->pdo->exec("CREATE TRIGGER reject_completion BEFORE UPDATE ON import_jobs" . " WHEN NEW.status = 'Completed' BEGIN SELECT RAISE(ABORT, 'injected completion failure'); END"); $this->worker()->runOnce(); self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn()); $item = $this->jobs->items($job)[0]; self::assertSame('planes/wing_001.stl', $item['managed_path']); self::assertSame('Failed', $item['status']); self::assertStringStartsWith('COPY_FAILED:', $item['failure_reason']); self::assertSame('Failed', $this->jobs->get($job)['status']); self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source)); $published = PhysicalFileSnapshot::capture($this->library); self::assertSame($libraryBefore['planes/wing.stl'], $published['planes/wing.stl']); self::assertArrayNotHasKey('planes/wing_001.stl', $published); } public function testImportCreatesJobWithLegacyGuessedSubfolderGrouping(): void { $this->writeStl('planes/wing.stl'); $this->writeStl('planes/body.stl'); $this->writeStl('loose.stl'); $job = $this->service()->import($this->source); $items = $this->jobs->items($job); self::assertCount(3, $items); self::assertSame('Pending', $this->jobs->get($job)['status']); $models = []; foreach ($items as $item) { self::assertNotNull($item['model_id']); $aggregate = $this->catalog->aggregate((int) $item['model_id']); $models[$aggregate['model']['name']] = $aggregate['folder']['path']; } // Legacy guessed grouping: one model per immediate source subfolder; root files join // a model named after the source directory itself. self::assertSame('/planes', $models['planes']); self::assertSame('/' . basename($this->source), $models[basename($this->source)]); } public function testImportRejectsEmptySourceAndInsufficientSpace(): void { try { $this->service()->import($this->source); self::fail('Empty source accepted'); } catch (ImportException $error) { self::assertSame('SCAN_EMPTY', $error->reason); } $this->writeStl('planes/wing.stl'); $full = new SpaceChecker(0.05, static fn (): float => 1.0); try { $this->service($full)->import($this->source); self::fail('Full disk accepted'); } catch (ImportException $error) { self::assertSame('DISK_FULL', $error->reason); } self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn()); } public function testWorkerCopiesPlansAndCompletesCatalog(): void { $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); self::assertSame($job, $this->worker()->runOnce()); $item = $this->jobs->items($job)[0]; self::assertSame('Completed', $item['status']); self::assertSame('Completed', $this->jobs->get($job)['status']); self::assertNotNull($item['managed_path']); $published = $this->library . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']); self::assertFileExists($published); self::assertSame(hash_file('sha256', $published), $item['hash']); $files = $this->catalog->aggregate((int) $item['model_id'])['files']; self::assertCount(1, $files); self::assertSame('Present', $files[0]['status']); self::assertSame($item['managed_path'], $files[0]['managed_path']); self::assertNull($this->worker()->runOnce()); } public function testFailedItemsDoNotStopRemainingWork(): void { $this->writeStl('planes/wing.stl'); $broken = $this->writeStl('planes/broken.stl'); file_put_contents($broken, 'not an stl at all'); $job = $this->service()->import($this->source); $this->worker()->runOnce(); $statuses = array_column($this->jobs->items($job), 'status'); self::assertContains('Failed', $statuses); self::assertContains('Completed', $statuses); self::assertSame('Failed', $this->jobs->get($job)['status']); self::assertSame(2, (int) $this->jobs->get($job)['processed_files']); $failure = array_values(array_filter( $this->jobs->items($job), static fn (array $item): bool => $item['status'] === 'Failed' ))[0]; self::assertStringContainsString('STL_INVALID', (string) $failure['failure_reason']); } public function testCrashRecoveryCompletesPlannedItemWithoutDuplicateCopy(): void { $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $this->worker()->runOnce(); $item = $this->jobs->items($job)[0]; // Simulate a crash after the copy but before the catalog/progress commit. $this->pdo->exec( "UPDATE import_items SET status = 'Copying', completed_at = NULL WHERE id = " . (int) $item['id'] ); $this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0 WHERE id = $job"); $this->pdo->exec('DELETE FROM files'); $this->jobs->resumeInterrupted(); self::assertSame($job, $this->worker()->runOnce()); self::assertSame('Completed', $this->jobs->items($job)[0]['status']); self::assertSame($item['managed_path'], $this->jobs->items($job)[0]['managed_path']); $directory = dirname($this->library . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $item['managed_path'])); self::assertCount(1, array_diff(scandir($directory), ['.', '..'])); self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']); } public function testSuffixIntentIsCommittedBeforeLinkAndRecoveryNeedsNoSource(): void { $source = $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); mkdir($this->library . '/planes'); file_put_contents($this->library . '/planes/wing.stl', 'occupied'); $observed = null; $this->worker(null, ['link' => function (string $temp, string $dest) use ($job, &$observed): bool { $reader = RepositoryDatabase::connect($this->database); $observed = $reader->query('SELECT managed_path FROM import_items WHERE job_id = ' . $job)->fetchColumn(); return link($temp, $dest); }])->runOnce(); self::assertSame('planes/wing_001.stl', $observed); $this->pdo->exec("UPDATE import_items SET status = 'Copying', completed_at = NULL"); $this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0"); $this->pdo->exec('DELETE FROM files'); unlink($source); $this->worker()->runOnce(); self::assertSame('Completed', $this->jobs->items($job)[0]['status']); self::assertFileDoesNotExist($this->library . '/planes/wing_002.stl'); } public function testPublicationRaceFailsWithoutSilentSuffix(): void { $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $this->worker(null, ['link' => static function (string $temp, string $dest): bool { file_put_contents($dest, 'race winner'); return false; }])->runOnce(); self::assertSame('Failed', $this->jobs->items($job)[0]['status']); self::assertSame('race winner', file_get_contents($this->library . '/planes/wing.stl')); self::assertFileDoesNotExist($this->library . '/planes/wing_001.stl'); } public function testAdoptedBytesSurviveCompletionFailure(): void { if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') { self::markTestSkipped('SQLite failure-injection trigger.'); } $source = $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $item = $this->jobs->items($job)[0]; $this->jobs->planItem((int) $item['id'], 'planes/wing.stl', hash_file('sha256', $source), filesize($source)); mkdir($this->library . '/planes'); copy($source, $this->library . '/planes/wing.stl'); $this->pdo->exec("CREATE TRIGGER reject_adoption BEFORE UPDATE ON import_jobs" . " WHEN NEW.status = 'Completed' BEGIN SELECT RAISE(ABORT, 'completion failure'); END"); $this->worker()->runOnce(); self::assertSame('Failed', $this->jobs->items($job)[0]['status']); self::assertSame(file_get_contents($source), file_get_contents($this->library . '/planes/wing.stl')); self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn()); } public function testGracefulStopLeavesJobResumable(): void { $this->writeStl('planes/wing.stl'); $this->writeStl('planes/body.stl'); $job = $this->service()->import($this->source); $calls = 0; $this->worker(static function () use (&$calls): bool { // Stop only after the first item has been processed. return $calls++ > 0; })->runOnce(); self::assertSame('Pending', $this->jobs->get($job)['status']); $statuses = array_column($this->jobs->items($job), 'status'); self::assertContains('Pending', $statuses); self::assertContains('Completed', $statuses); self::assertSame($job, $this->worker()->runOnce()); self::assertSame('Completed', $this->jobs->get($job)['status']); self::assertSame(2, (int) $this->jobs->get($job)['processed_files']); } public function testConcurrentWorkerCannotHoldTheLibraryLock(): void { $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $lock = fopen($this->library . DIRECTORY_SEPARATOR . '.meshvault-import.lock', 'c'); self::assertNotFalse($lock); self::assertTrue(flock($lock, LOCK_EX | LOCK_NB)); try { $this->worker()->runOnce(); self::fail('Worker ignored the global library lock'); } catch (ImportException $error) { self::assertSame('LOCK_UNAVAILABLE', $error->reason); } finally { flock($lock, LOCK_UN); fclose($lock); } self::assertSame('Pending', $this->jobs->get($job)['status']); } public function testTwoSameBasenameSourcesPublishWithCollisionSuffixAndBothRowsPointToRealFiles(): void { // Two sources with same basename but DIFFERENT content in different subdirectories $this->writeStl('planes/wing.stl', 'wing1'); $this->writeStl('planes/sub/wing.stl', 'wing2'); $job = $this->service()->import($this->source); $this->worker()->runOnce(); $items = $this->jobs->items($job); self::assertCount(2, $items); // Both should complete successfully $statuses = array_column($items, 'status'); self::assertSame(['Completed', 'Completed'], $statuses); // Both managed_path entries should point to actual published files (with suffixes) foreach ($items as $item) { $published = $this->library . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']); self::assertFileExists($published, "Published file missing for managed_path: " . $item['managed_path']); self::assertSame(hash_file('sha256', $published), $item['hash']); } // Filenames should differ (one gets _001 suffix) $basenames = array_map('basename', array_column($items, 'managed_path')); self::assertNotSame($basenames[0], $basenames[1], 'Collision suffix not applied - both have same basename'); self::assertMatchesRegularExpression('/^wing(_001)?\\.stl$/', $basenames[0]); self::assertMatchesRegularExpression('/^wing(_001)?\\.stl$/', $basenames[1]); } public function testCrashAfterPublishBeforeCompletionResumesWithoutRecopyOrCollision(): void { $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $this->worker()->runOnce(); $item = $this->jobs->items($job)[0]; // Simulate crash AFTER copy but BEFORE catalog/progress commit // Set item to Copying (plan already committed, file already copied) $this->pdo->exec( "UPDATE import_items SET status = 'Copying', completed_at = NULL WHERE id = " . (int) $item['id'] ); $this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0 WHERE id = $job"); $this->pdo->exec('DELETE FROM files'); // Remove catalog entry but keep file on disk // Resume and run again $this->jobs->resumeInterrupted(); $resumedJob = $this->worker()->runOnce(); self::assertSame($job, $resumedJob); // Item should complete without re-copying $resumedItem = $this->jobs->items($job)[0]; self::assertSame('Completed', $resumedItem['status']); self::assertSame($item['managed_path'], $resumedItem['managed_path'], 'managed_path changed on resume'); // Only ONE file should exist in the destination directory (no duplicate copy) $directory = dirname($this->library . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $item['managed_path'])); self::assertCount(1, array_diff(scandir($directory), ['.', '..']), 'Duplicate file created on resume'); // Catalog should have exactly one entry self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']); } public function testAdoptExistingRevalidatesWithPathSafetyAndRejectsLinks(): void { // Create a legitimate file first $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $this->worker()->runOnce(); $item = $this->jobs->items($job)[0]; $managedPath = $item['managed_path']; $publishedPath = $this->library . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $managedPath); // Verify it exists and is a regular file self::assertFileExists($publishedPath); self::assertFalse(is_link($publishedPath)); // Now simulate a crash and replace the file with a symlink $this->pdo->exec( "UPDATE import_items SET status = 'Copying', completed_at = NULL WHERE id = " . (int) $item['id'] ); $this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0 WHERE id = $job"); $this->pdo->exec('DELETE FROM files'); // Replace the file with a symlink pointing outside the library $outside = $this->source . DIRECTORY_SEPARATOR . 'outside.stl'; file_put_contents($outside, 'malicious'); unlink($publishedPath); if (!@symlink($outside, $publishedPath)) { self::markTestSkipped('Host cannot create symlinks.'); } // Resume - should reject the symlink and fail the item $this->jobs->resumeInterrupted(); $this->worker()->runOnce(); $resumedItem = $this->jobs->items($job)[0]; self::assertSame('Failed', $resumedItem['status']); self::assertStringContainsString('SYMLINK_ESCAPE', $resumedItem['failure_reason']); } public function testSpaceCheckerPreflightBeforeEachCopy(): void { $this->writeStl('planes/wing.stl'); $this->writeStl('planes/body.stl'); $job = $this->service()->import($this->source); // Simulate disk full on second item $spaceChecks = 0; $spaceChecker = new SpaceChecker(0.05, static function (string $path) use (&$spaceChecks): float { $spaceChecks++; // First check passes, second fails return $spaceChecks === 1 ? 1000000.0 : 100.0; }); // Create worker with custom SpaceChecker $safety = new PathSafety($this->library); $sourceSafety = new PathSafety($this->source); $worker = new ImportWorker( $this->jobs, new \MeshVault\Catalog\CatalogService($this->catalog), new StreamingHasher($sourceSafety), new VerifiedCopier($safety), new StlValidator($sourceSafety), $safety, $spaceChecker, $this->library, new NullLogger(), ); $worker->runOnce(); $items = $this->jobs->items($job); $statuses = array_column($items, 'status'); // First item should complete, second should fail with DISK_FULL self::assertSame('Completed', $statuses[0]); self::assertSame('Failed', $statuses[1]); self::assertStringContainsString('DISK_FULL', $items[1]['failure_reason']); // Job should be Failed overall but first item completed self::assertSame('Failed', $this->jobs->get($job)['status']); } public function testWorkerUsesCanonicalLibraryRootForRelativePaths(): void { // A trailing separator is safe but differs from the canonical root. // Symlink roots are deliberately rejected by PathSafety. $rawLibrary = $this->library . DIRECTORY_SEPARATOR; $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); $safety = new PathSafety($rawLibrary); self::assertNotSame($rawLibrary, $safety->libraryRoot()); $sourceSafety = new PathSafety($this->source); $worker = new ImportWorker( $this->jobs, new \MeshVault\Catalog\CatalogService($this->catalog), new StreamingHasher($sourceSafety), new VerifiedCopier($safety), new StlValidator($sourceSafety), $safety, new SpaceChecker(), $rawLibrary, new NullLogger(), ); $worker->runOnce(); $item = $this->jobs->items($job)[0]; self::assertSame('Completed', $item['status']); self::assertStringStartsWith('planes/', $item['managed_path']); $published = $this->library . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']); self::assertFileExists($published); } }