Files
MeshVault/tests/Integration/LegacyGroupingAdapterTest.php
Cline a829d6e8c6 feat: catalog shared contracts (Tasks 1-4)
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.
2026-10-13 19:41:03 -05:00

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