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.
193 lines
7.9 KiB
PHP
193 lines
7.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MeshVault\Tests\Integration;
|
|
|
|
use DomainException;
|
|
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
|
|
use MeshVault\Indexer\ImportException;
|
|
use MeshVault\Indexer\ImportService;
|
|
use MeshVault\Indexer\LegacyGuessedImportGroupingAdapter;
|
|
use MeshVault\Indexer\ScannedFile;
|
|
use MeshVault\Indexer\ScanResult;
|
|
use MeshVault\Indexer\SpaceChecker;
|
|
use MeshVault\Repository\PdoCatalogRepository;
|
|
use MeshVault\Repository\PdoImportJobRepository;
|
|
use PDO;
|
|
use PHPUnit\Framework\TestCase;
|
|
use RuntimeException;
|
|
|
|
final class LegacyGroupingAdapterTest extends TestCase
|
|
{
|
|
private PDO $pdo;
|
|
private string $database;
|
|
private string $library;
|
|
private string $source;
|
|
private PdoCatalogRepository $catalog;
|
|
private PdoImportJobRepository $jobs;
|
|
private LegacyGuessedImportGroupingAdapter $adapter;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-grouping-');
|
|
$this->pdo = RepositoryDatabase::migrate($this->database);
|
|
$this->catalog = new PdoCatalogRepository($this->pdo);
|
|
$this->jobs = new PdoImportJobRepository($this->pdo);
|
|
$this->adapter = new LegacyGuessedImportGroupingAdapter($this->catalog);
|
|
$this->library = RepositoryDatabase::directory('library');
|
|
$this->source = RepositoryDatabase::directory('source');
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
unset($this->adapter, $this->catalog, $this->jobs, $this->pdo);
|
|
RepositoryDatabase::cleanup($this->database);
|
|
RepositoryDatabase::remove($this->library);
|
|
RepositoryDatabase::remove($this->source);
|
|
}
|
|
|
|
public function testSameAdapterResolvesPersistedModelAfterRollback(): void
|
|
{
|
|
$file = $this->file('planes/wing.stl');
|
|
$before = $this->snapshot();
|
|
$failure = new RuntimeException('Force rollback after grouping.');
|
|
try {
|
|
$this->jobs->atomic(function () use ($file, $failure): void {
|
|
$id = $this->adapter->modelFor($this->source, $file);
|
|
self::assertSame($id, (int) $this->pdo->query('SELECT id FROM models')->fetchColumn());
|
|
throw $failure;
|
|
});
|
|
self::fail('The transaction must roll back.');
|
|
} catch (RuntimeException $error) {
|
|
self::assertSame($failure, $error);
|
|
}
|
|
self::assertSame($before, $this->snapshot());
|
|
|
|
// SQLite can reuse rolled-back IDs: require an actual persisted row, not a different ID.
|
|
$id = $this->adapter->modelFor($this->source, $file);
|
|
$rows = $this->pdo->query(
|
|
'SELECT m.id, m.name, f.path FROM models m JOIN folders f ON f.id = m.folder_id'
|
|
)->fetchAll(PDO::FETCH_ASSOC);
|
|
self::assertCount(1, $rows);
|
|
self::assertSame($id, (int) $rows[0]['id']);
|
|
self::assertSame('planes', $rows[0]['name']);
|
|
self::assertSame('/planes', $rows[0]['path']);
|
|
self::assertSame($id, $this->adapter->modelFor($this->source, $file));
|
|
}
|
|
|
|
public function testSameAdapterRejectsAmbiguityIntroducedBetweenCalls(): void
|
|
{
|
|
$file = $this->file('planes/wing.stl');
|
|
$first = $this->adapter->modelFor($this->source, $file);
|
|
$second = $this->catalog->createModel('/planes', 'planes');
|
|
self::assertNotSame($first, $second);
|
|
$before = $this->snapshot();
|
|
|
|
try {
|
|
$this->adapter->modelFor($this->source, $file);
|
|
self::fail('A previously resolved title must not hide new ambiguity.');
|
|
} catch (DomainException $error) {
|
|
self::assertSame('Ambiguous model title in home folder.', $error->getMessage());
|
|
}
|
|
self::assertSame($before, $this->snapshot());
|
|
}
|
|
|
|
public function testRepeatedImportsReuseModelsWithTheSameServiceAndAdapter(): void
|
|
{
|
|
$service = $this->service([$this->file('planes/wing.stl'), $this->file('planes/nested/body.stl')]);
|
|
$first = $service->import($this->source);
|
|
$before = $this->snapshot();
|
|
$second = $service->import($this->source);
|
|
|
|
self::assertNotSame($first, $second);
|
|
$firstItems = $this->jobs->items($first);
|
|
$secondItems = $this->jobs->items($second);
|
|
self::assertCount(2, $firstItems);
|
|
self::assertCount(2, $secondItems);
|
|
$model = (int) $firstItems[0]['model_id'];
|
|
self::assertGreaterThan(0, $model);
|
|
foreach (array_merge($firstItems, $secondItems) as $item) {
|
|
self::assertSame($model, (int) $item['model_id']);
|
|
self::assertSame('Pending', $item['status']);
|
|
}
|
|
$after = $this->snapshot();
|
|
self::assertSame($before['models'], $after['models']);
|
|
self::assertSame($before['folders'], $after['folders']);
|
|
self::assertCount(1, $after['models']);
|
|
self::assertCount(2, $after['import_jobs']);
|
|
self::assertCount(4, $after['import_items']);
|
|
}
|
|
|
|
|
|
public function testAmbiguityBetweenImportsRollsBackEarlierManifestModelCreation(): void
|
|
{
|
|
$plane = $this->file('planes/wing.stl');
|
|
$service = $this->service([$plane], [$this->file('new-group/part.stl'), $plane]);
|
|
$firstJob = $service->import($this->source);
|
|
$firstModel = (int) $this->jobs->items($firstJob)[0]['model_id'];
|
|
$duplicate = $this->catalog->createModel('/planes', 'planes');
|
|
self::assertNotSame($firstModel, $duplicate);
|
|
$before = $this->snapshot();
|
|
|
|
try {
|
|
// The ordered scan creates /new-group before encountering the now-ambiguous title.
|
|
$service->import($this->source);
|
|
self::fail('An ambiguous title must reject the entire import.');
|
|
} catch (ImportException $error) {
|
|
self::assertSame('DB_ERROR', $error->reason);
|
|
self::assertInstanceOf(DomainException::class, $error->getPrevious());
|
|
self::assertSame('Ambiguous model title in home folder.', $error->getPrevious()->getMessage());
|
|
}
|
|
|
|
self::assertFalse($this->pdo->inTransaction());
|
|
self::assertSame($before, $this->snapshot(), 'No model, folder, job or item may survive the failed import.');
|
|
self::assertSame(0, (int) $this->pdo->query(
|
|
"SELECT COUNT(*) FROM folders WHERE path = '/new-group'"
|
|
)->fetchColumn());
|
|
}
|
|
|
|
private function file(string $relative): ScannedFile
|
|
{
|
|
return new ScannedFile($this->source . '/' . $relative, $relative, 12, 'model/stl');
|
|
}
|
|
|
|
/** @param list<ScannedFile> $files @param list<ScannedFile>|null $nextFiles */
|
|
private function service(array $files, ?array $nextFiles = null): ImportService
|
|
{
|
|
// Control scan order so creation precedes ambiguity. Grouping and persistence remain real.
|
|
$enumerator = new class ($files, $nextFiles) implements FileEnumeratorInterface {
|
|
public function __construct(private array $files, private ?array $nextFiles)
|
|
{
|
|
}
|
|
|
|
public function enumerate(string $sourceRoot, array $extensions = self::DEFAULT_EXTENSIONS): ScanResult
|
|
{
|
|
$files = $this->files;
|
|
$this->files = $this->nextFiles ?? $files;
|
|
$bytes = array_sum(array_map(static fn (ScannedFile $file): int => $file->size, $files));
|
|
return new ScanResult($files, $bytes, []);
|
|
}
|
|
};
|
|
|
|
return new ImportService(
|
|
$enumerator,
|
|
$this->jobs,
|
|
$this->adapter,
|
|
new SpaceChecker(0.05, static fn (string $path): float => 1000000.0),
|
|
$this->library,
|
|
);
|
|
}
|
|
|
|
/** @return array<string, list<array<string, mixed>>> */
|
|
private function snapshot(): array
|
|
{
|
|
$snapshot = [];
|
|
foreach (['folders', 'models', 'import_jobs', 'import_items'] as $table) {
|
|
$snapshot[$table] = $this->pdo->query('SELECT * FROM ' . $table . ' ORDER BY id')
|
|
->fetchAll(PDO::FETCH_ASSOC);
|
|
}
|
|
return $snapshot;
|
|
}
|
|
}
|