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 $files @param list|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>> */ 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; } }