647 lines
29 KiB
PHP
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);
|
|
}
|
|
}
|