feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console

This commit is contained in:
Cline committed 2026-10-12 11:29:17 -05:00
1 parent 93d991703c
commit 9d277d9e72
48 files changed
+5147 -4

No files matched your search

+418
View File
@@ -0,0 +1,418 @@
<?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);
@unlink($this->database);
RepositoryDatabase::remove($this->library);
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,
$this->catalog,
$space ?? new SpaceChecker(),
$this->library,
);
}
private function worker(?callable $shouldStop = null): ImportWorker
{
$safety = new PathSafety($this->library);
$sourceSafety = new PathSafety($this->source);
return new ImportWorker(
$this->jobs,
$this->catalog,
new StreamingHasher($sourceSafety),
new VerifiedCopier($safety),
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 testImportCreatesJobWithExplicitSubfolderGrouping(): 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'];
}
// Explicit policy: 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 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,
$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
{
// Test that the worker uses canonical (realpath) library root for
// computing relative paths, not the raw string which might have
// symlinks or case differences.
//
// Create library with a symlink in the path
$realLibrary = $this->library;
$linkLibrary = $realLibrary . '-link';
if (!@symlink($realLibrary, $linkLibrary)) {
self::markTestSkipped('Host cannot create symlinks.');
}
$this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
// Create worker with symlinked library root
$safety = new PathSafety($linkLibrary);
$sourceSafety = new PathSafety($this->source);
$worker = new ImportWorker(
$this->jobs,
$this->catalog,
new StreamingHasher($sourceSafety),
new VerifiedCopier($safety),
new StlValidator($sourceSafety),
$safety,
new SpaceChecker(),
$linkLibrary, // Symlinked path
new NullLogger(),
);
$worker->runOnce();
$item = $this->jobs->items($job)[0];
// The managed_path should be relative to the CANONICAL root, not the symlink
self::assertStringNotContainsString('link', $item['managed_path']);
$published = $this->library . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']);
self::assertFileExists($published);
// Cleanup
unlink($linkLibrary);
}
}
+1 -1
View File
@@ -79,4 +79,4 @@ final class MigrationTest extends TestCase
$this->assertContains('completed', $tags);
$this->assertContains('print-ready', $tags);
}
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use PDO;
use Phinx\Config\Config;
use Phinx\Migration\Manager;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/** Runs the production migrations against SQLite and manages scratch directories. */
final class RepositoryDatabase
{
/** Never a hand-maintained test schema: drift must fail the suite. */
public static function migrate(string $path): PDO
{
$pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$config = new Config([
'paths' => ['migrations' => dirname(__DIR__, 2) . '/db/migrations'],
'environments' => [
'default_migration_table' => 'phinxlog',
'default_database' => 'test',
'test' => ['adapter' => 'sqlite', 'connection' => $pdo],
],
]);
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA busy_timeout = 5000');
return $pdo;
}
public static function directory(string $prefix): string
{
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'meshvault-' . $prefix . '-' . bin2hex(random_bytes(6));
mkdir($path, 0777, true);
return (string) realpath($path);
}
public static function remove(string $path): void
{
foreach (array_diff(@scandir($path) ?: [], ['.', '..']) as $entry) {
$child = $path . DIRECTORY_SEPARATOR . $entry;
is_dir($child) ? self::remove($child) : @unlink($child);
}
@rmdir($path);
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use DomainException;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use MeshVault\Repository\PdoCatalogRepository;
use MeshVault\Repository\PdoImportJobRepository;
use PDO;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class RepositoryTest extends TestCase
{
private PDO $pdo;
private string $database;
protected function setUp(): void
{
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-repository-');
$this->pdo = RepositoryDatabase::migrate($this->database);
}
protected function tearDown(): void
{
unset($this->pdo);
unlink($this->database);
}
public function testCatalogGroupingAndFileLifecycle(): void
{
self::assertTrue(class_exists(PdoCatalogRepository::class), 'Catalog repository must exist');
$catalog = new PdoCatalogRepository($this->pdo);
self::assertInstanceOf(CatalogRepositoryInterface::class, $catalog);
$model = $catalog->ensureModel('/vehicles/aircraft', 'Plane');
self::assertSame($model, $catalog->ensureModel('/vehicles/aircraft', 'Plane'));
self::assertNotSame($model, $catalog->ensureModel('/vehicles/aircraft', 'Other'));
$root = $catalog->ensureModel('/', 'Root');
self::assertNull($catalog->aggregate($root)['folder']['parent_id']);
self::assertSame(3, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn());
$file = $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12, 'model/stl');
self::assertSame($file, $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12));
foreach (['Missing', 'Updated', 'Duplicate', 'Present'] as $status) {
$catalog->setFileStatus($file, $status);
self::assertSame($status, $catalog->aggregate($model)['files'][0]['status']);
}
$aggregate = $catalog->aggregate($model);
self::assertSame('Plane', $aggregate['model']['name']);
self::assertSame('/vehicles/aircraft', $aggregate['folder']['path']);
self::assertSame('model/stl', $aggregate['files'][0]['mime_type']);
self::assertNull($catalog->aggregate(99999));
$this->expectException(DomainException::class);
$catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('b', 64), 12);
}
public function testRecoveryReleaseCancellationFailureAndEmptyJobs(): void
{
self::assertTrue(class_exists(PdoImportJobRepository::class));
$jobs = new PdoImportJobRepository($this->pdo);
$manifest = [['source_path' => '/a', 'size' => 5, 'model_id' => null]];
$job = $jobs->create('/', $manifest);
$jobs->claimNext();
$item = (int) $jobs->items($job)[0]['id'];
$jobs->planItem($item, 'a', 'hash', 5);
$jobs->release($job);
self::assertSame('Pending', $jobs->get($job)['status']);
$jobs->claimNext();
$jobs->resumeInterrupted();
self::assertSame('Pending', $jobs->items($job)[0]['status']);
self::assertSame('a', $jobs->items($job)[0]['managed_path']);
$this->pdo->exec("UPDATE import_jobs SET status = 'Scanning' WHERE id = $job");
$jobs->resumeInterrupted();
self::assertSame('Pending', $jobs->get($job)['status']);
$jobs->cancel($job);
$jobs->cancel($job);
$jobs->resumeInterrupted();
self::assertSame('Cancelled', $jobs->get($job)['status']);
self::assertSame('Skipped', $jobs->items($job)[0]['status']);
$failed = $jobs->create('/', $manifest);
$jobs->fail($failed, 'disk full');
$jobs->fail($failed, 'disk full');
self::assertSame('Failed', $jobs->get($failed)['status']);
self::assertSame('disk full', $jobs->items($failed)[0]['failure_reason']);
$empty = $jobs->create('/', []);
$jobs->completeEmpty($empty);
$jobs->completeEmpty($empty);
self::assertSame('Completed', $jobs->get($empty)['status']);
$scan = $jobs->create('/', [
['source_path' => '/bad', 'size' => 0, 'model_id' => null,
'status' => 'Failed', 'failure_reason' => 'scan failed'],
]);
self::assertSame('Failed', $jobs->get($scan)['status']);
self::assertSame(1, (int) $jobs->get($scan)['processed_files']);
self::assertNull($jobs->claimNext());
}
public function testImmutablePlansAndSuccessfulCompletion(): void
{
self::assertTrue(class_exists(PdoImportJobRepository::class));
$jobs = new PdoImportJobRepository($this->pdo);
$job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]);
$jobs->claimNext();
$item = (int) $jobs->items($job)[0]['id'];
$jobs->planItem($item, 'a', 'hash', 5);
foreach ([['b', 'hash', 5], ['a', 'other', 5], ['a', 'hash', 6]] as $plan) {
try {
$jobs->planItem($item, ...$plan);
self::fail('Plan mutation accepted');
} catch (DomainException) {
self::assertSame('a', $jobs->items($job)[0]['managed_path']);
}
}
$jobs->finishItem($item, 'Completed');
self::assertSame('Completed', $jobs->get($job)['status']);
$this->expectException(DomainException::class);
$jobs->finishItem($item, 'Failed', 'late failure');
}
public function testAtomicRollbackAndIndependentClaims(): void
{
self::assertTrue(class_exists(PdoImportJobRepository::class));
$jobs = new PdoImportJobRepository($this->pdo);
$catalog = new PdoCatalogRepository($this->pdo);
$job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]);
$second = $jobs->create('/', []);
$other = new PdoImportJobRepository(new PDO('sqlite:' . $this->database));
self::assertSame($job, (int) $jobs->claimNext()['id']);
self::assertSame($second, (int) $other->claimNext()['id']);
self::assertNull($other->claimNext());
try {
$jobs->atomic(function () use ($jobs, $catalog, $job): void {
$model = $catalog->ensureModel('/rollback', 'Rollback');
$catalog->recordFile($model, 'rollback/a', 'hash', 5);
$jobs->finishItem((int) $jobs->items($job)[0]['id'], 'Completed');
throw new RuntimeException('rollback');
});
self::fail('Expected rollback');
} catch (RuntimeException $error) {
self::assertSame('rollback', $error->getMessage());
}
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM models')->fetchColumn());
self::assertSame('Pending', $jobs->items($job)[0]['status']);
self::assertSame(42, $jobs->atomic(static fn (): int => 42));
$before = (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn();
try {
$jobs->create('/', [
['source_path' => '/valid', 'size' => 1, 'model_id' => null],
['source_path' => '/invalid', 'size' => 1, 'model_id' => 99999],
]);
self::fail('Expected foreign key rejection');
} catch (\PDOException) {
self::assertSame($before, (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn());
}
}
public function testManifestClaimPlanProgressAndIdempotency(): void
{
self::assertTrue(class_exists(PdoImportJobRepository::class), 'Job repository must exist');
$jobs = new PdoImportJobRepository($this->pdo);
self::assertInstanceOf(ImportJobRepositoryInterface::class, $jobs);
$job = $jobs->create('/source', [
['source_path' => '/source/a', 'size' => 10, 'model_id' => null],
['source_path' => '/source/b', 'size' => 20, 'model_id' => null],
['source_path' => '/source/c', 'size' => 30, 'model_id' => null],
]);
self::assertSame('Pending', $jobs->get($job)['status']);
self::assertSame(60, (int) $jobs->get($job)['total_bytes']);
self::assertSame($job, (int) $jobs->claimNext()['id']);
self::assertNotNull($jobs->get($job)['started_at']);
self::assertNull($jobs->claimNext());
$items = $jobs->items($job);
$jobs->planItem((int) $items[0]['id'], 'a.stl', str_repeat('a', 64), 10);
$jobs->planItem((int) $items[0]['id'], 'a.stl', str_repeat('a', 64), 10);
self::assertSame('Copying', $jobs->items($job)[0]['status']);
self::assertSame('a.stl', $jobs->items($job)[0]['managed_path']);
$jobs->finishItem((int) $items[0]['id'], 'Completed');
$jobs->finishItem((int) $items[0]['id'], 'Completed');
self::assertSame(1, (int) $jobs->get($job)['processed_files']);
self::assertSame(10, (int) $jobs->get($job)['copied_bytes']);
$jobs->finishItem((int) $items[1]['id'], 'Skipped');
$jobs->finishItem((int) $items[2]['id'], 'Failed', 'unreadable');
self::assertSame('Failed', $jobs->get($job)['status']);
self::assertSame(3, (int) $jobs->get($job)['processed_files']);
self::assertSame('unreadable', $jobs->items($job)[2]['failure_reason']);
self::assertNotNull($jobs->get($job)['completed_at']);
self::assertNull($jobs->get(99999));
self::assertSame([], $jobs->items(99999));
}
}
+8
View File
@@ -43,6 +43,14 @@ final class TaskRunnerTest extends TestCase
self::assertStringContainsString('compose', $output);
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
}
// Import engine entrypoints must reach bin/meshvault with their arguments intact.
foreach (['import', 'worker', 'worker-once'] as $command) {
[$code, $output] = $this->invoke([...$runner, $command, '--probe'], $temporary, $environment);
self::assertSame(37, $code, $command . ': ' . $output);
self::assertStringContainsString('bin/meshvault', $output);
self::assertStringContainsString($command, $output);
self::assertStringContainsString('--probe', $output);
}
foreach (['unknown'] as $command) {
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
self::assertNotSame(0, $code);