Files

647 lines
29 KiB
PHP

<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use MeshVault\Indexer\FileEnumerator;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\ImportException;
use MeshVault\Indexer\ImportService;
use MeshVault\Indexer\ImportWorker;
use MeshVault\Indexer\SpaceChecker;
use MeshVault\Indexer\StlValidator;
use MeshVault\Indexer\StreamingHasher;
use MeshVault\Indexer\VerifiedCopier;
use MeshVault\Repository\PdoCatalogRepository;
use MeshVault\Repository\PdoImportJobRepository;
use PDO;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
final class ImportPipelineTest extends TestCase
{
private PDO $pdo;
private string $database;
private string $library;
private string $source;
private PdoImportJobRepository $jobs;
private PdoCatalogRepository $catalog;
protected function setUp(): void
{
$this->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);
}
}