Files
MeshVault/tests/Integration/RepositoryTest.php
T
Cline c1ef2ee97a test: validate M3 imports and locking on PostgreSQL
Add isolated PostgreSQL fixtures, two-session locking coverage, and a disposable remote Docker test runner. Correct the canonical-root fixture and document verified SQLite and PostgreSQL results.
2026-10-12 13:19:10 -05:00

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->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(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));
}
}