Task 1: Establish catalog identity and atomic file ownership - CatalogService, CatalogServiceInterface, ModelMetadata DTO - PdoCatalogRepository with atomic operations, savepoint support - FOR UPDATE file locks in ascending order, FOR NO KEY UPDATE for metadata - FK-compatible lock composition documented (spec §6) Task 2: M3 import & indexing engine delivery layer - ImportService, ImportWorker with durable collision handling - SpaceChecker, StreamingHasher, VerifiedCopier, StlValidator - FileEnumerator, LocalFilesystem, PathSafety - Console command wiring via ContainerWiringTest Task 3: Importer integration and repair - LegacyGuessedImportGroupingAdapter isolates first-directory guessing - ImportService uses adapter; ImportWorker uses CatalogServiceInterface - Null owners remain null; DomainException mapped to CATALOG_CONFLICT - SQL failure after registration/path update with caller sentinel rollback Task 4: PostgreSQL contention evidence and bounded acceptance - 6 new PostgresCatalogContractsTest cases with independent sessions - Generated schemas, 250ms lock timeouts, 5s statement timeouts - Ascending file locks, FOR SHARE contention, stale-owner rejection - Rollback/retry, nested transaction survival, root creation contention - Negative controls: replay FOR UPDATE removal (11 failures), metadata FOR UPDATE (4 FK KEY SHARE timeouts) - Fresh Docker PostgreSQL 17 / PHP 8.4.26: 473 tests, 2718 assertions, 10 skips, exit 0 All local: 474 tests, 2347 assertions, 54 skips, foundation/lint clean.
201 lines
9.7 KiB
PHP
201 lines
9.7 KiB
PHP
<?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);
|
|
RepositoryDatabase::cleanup($this->database);
|
|
}
|
|
|
|
public function testSecondConnectionUsesTheMigratedDatabase(): void
|
|
{
|
|
$jobs = new PdoImportJobRepository($this->pdo);
|
|
$job = $jobs->create('/shared', []);
|
|
$other = new PdoImportJobRepository(RepositoryDatabase::connect($this->database));
|
|
self::assertSame('/shared', $other->get($job)['source_path']);
|
|
}
|
|
|
|
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->changeFileStatus($file, $catalog->file($file)['status'], $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(RepositoryDatabase::connect($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));
|
|
}
|
|
}
|