feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console
This commit is contained in:
1 parent
93d991703c
commit
9d277d9e72
48 files changed
+5147
-4
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user