database = tempnam(sys_get_temp_dir(), 'meshvault-core-'); $this->pdo = RepositoryDatabase::migrate($this->database); $this->catalog = new PdoCatalogRepository($this->pdo); } protected function tearDown(): void { unset($this->catalog, $this->pdo); gc_collect_cycles(); RepositoryDatabase::cleanup($this->database); } public function testExplicitIdentityAndLegacyCountedLookup(): void { $first = $this->catalog->ensureModel('/parts', ' Plane '); self::assertSame($first, $this->catalog->ensureModel('/parts', ' Plane ')); $second = $this->catalog->createModel('/parts', ' Plane '); self::assertNotSame($first, $second); foreach ([$first, $second] as $id) { $aggregate = $this->catalog->aggregate($id); self::assertSame(' Plane ', $aggregate['model']['name']); self::assertSame('/parts', $aggregate['folder']['path']); self::assertSame([], $aggregate['files']); } self::assertNull($this->catalog->aggregate(999999)); try { $this->catalog->ensureModel('/parts', ' Plane '); self::fail('Ambiguous legacy lookup must fail'); } catch (DomainException) { self::assertSame(2, $this->countRows('models')); } } #[DataProvider('invalidModels')] public function testInvalidModelInputsWriteNothing(string $folder, string $title): void { foreach (['createModel', 'ensureModel'] as $method) { try { $this->catalog->$method($folder, $title); self::fail('Invalid model must fail'); } catch (InvalidArgumentException) { self::assertSame(0, $this->countRows('models')); self::assertSame(0, $this->countRows('folders')); } } } public static function invalidModels(): array { return [['', 'Title'], ['/', ''], ['/', " \t\n"], ['/', str_repeat('a', 256)], ['/', str_repeat('é', 256)], ['../escape', 'Title']]; } public function testUnicodeTitleLimitCountsCharactersAndRootIsValid(): void { foreach (['createModel', 'ensureModel'] as $method) { $id = $this->catalog->$method('/', str_repeat('é', 255)); self::assertSame(str_repeat('é', 255), $this->catalog->aggregate($id)['model']['name']); self::assertSame('/', $this->catalog->aggregate($id)['folder']['path']); } } public function testNullableRegistrationIsIdempotentWithoutChangingMetadata(): void { $id = $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12, 'model/stl', 'Missing'); $before = $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC); self::assertNull($before['model_id']); self::assertSame($id, $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12)); self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC)); $model = $this->catalog->createModel('/elsewhere', 'Title'); $owned = $this->catalog->recordFile($model, 'other.stl', 'hash', 12); self::assertSame($owned, $this->catalog->recordFile($model, 'other.stl', 'hash', 12)); self::assertSame('/elsewhere', $this->catalog->aggregate($model)['folder']['path']); } #[DataProvider('registrationConflicts')] public function testRegistrationNeverAdoptsOrOverwrites(string $conflict): void { $model = $this->catalog->ensureModel('/', 'Title'); $owner = $conflict === 'detach' ? $model : null; $this->catalog->recordFile($owner, 'part.stl', 'hash', 12); $before = $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC); try { $this->catalog->recordFile( $conflict === 'adopt' ? $model : null, 'part.stl', $conflict === 'hash' ? 'different' : 'hash', $conflict === 'size' ? 13 : 12 ); self::fail('Conflicting registration must fail'); } catch (DomainException) { self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC)); } } public static function registrationConflicts(): array { return [['adopt'], ['detach'], ['hash'], ['size']]; } #[DataProvider('invalidRegistrations')] public function testInvalidRegistrationWritesNothing(?int $owner, string $path, string $hash, int $size): void { try { $this->catalog->recordFile($owner, $path, $hash, $size); self::fail('Invalid registration must fail'); } catch (InvalidArgumentException) { self::assertSame(0, $this->countRows('files')); self::assertSame(0, $this->countRows('folders')); } } public static function invalidRegistrations(): array { $cases = []; foreach (['', '/absolute', 'a\\b', 'a//b', 'a/./b', 'a/../b', "a\0b", 'https://host/a'] as $path) { $cases[] = [null, $path, 'hash', 1]; } return array_merge($cases, [[0, 'a', 'hash', 1], [-1, 'a', 'hash', 1], [null, 'a', '', 1], [null, 'a', 'hash', -1]]); } public function testAtomicReturnsValuesAndPreservesCallerTransaction(): void { $this->pdo->beginTransaction(); $outer = $this->catalog->ensureModel('/', 'Outer'); $id = $this->catalog->atomic(fn () => $this->catalog->createModel('/', 'Inner')); self::assertNotSame($outer, $id); self::assertTrue($this->pdo->inTransaction()); try { $this->catalog->atomic(function (): void { $this->catalog->createModel('/rollback', 'Rollback'); $this->catalog->recordFile(null, 'rollback/file.stl', 'hash', 1); throw new RuntimeException('abort'); }); self::fail('Expected failure'); } catch (RuntimeException $error) { self::assertSame('abort', $error->getMessage()); } self::assertTrue($this->pdo->inTransaction()); self::assertSame(2, $this->countRows('models')); self::assertSame(1, $this->countRows('folders')); self::assertSame(0, $this->countRows('files')); $this->pdo->rollBack(); self::assertSame(0, $this->countRows('models')); self::assertSame('result', $this->catalog->atomic(static fn () => 'result')); self::assertFalse($this->pdo->inTransaction()); } #[DataProvider('invalidAssociations')] public function testInvalidAssociationArguments(array $ids, ?int $expected, ?int $target): void { try { $this->catalog->changeFileOwner($ids, $expected, $target); self::fail('Invalid association must fail'); } catch (InvalidArgumentException) { self::assertSame(0, $this->countRows('files')); self::assertFalse($this->pdo->inTransaction()); } } public static function invalidAssociations(): array { return [[[], null, 1], [[1, 1], null, 1], [['1'], null, 1], [[1.0], null, 1], [[true], null, 1], [[null], null, 1], [[0], null, 1], [[-1], null, 1], [[1], 0, 1], [[1], -1, 1], [[1], null, 0], [[1], null, -1], [[2 => 1], null, 1]]; } #[DataProvider('associationConflicts')] public function testAssociationConflictsPreserveEveryRow(string $case): void { $first = $this->catalog->createModel('/first', 'First'); $second = $this->catalog->createModel('/second', 'Second'); $one = $this->catalog->recordFile($first, 'one.stl', 'one', 1); $two = $this->catalog->recordFile($case === 'mixed-owner' ? $second : $first, 'two.stl', 'two', 2); $before = $this->fileRows(); $expected = match ($case) { 'missing-expected' => 999999, 'null-expected' => null, 'stale-expected' => $second, default => $first, }; try { $this->catalog->changeFileOwner( [$one, $case === 'missing-file' ? 999999 : $two], $expected, $case === 'missing-target' ? 999999 : $second ); self::fail('Conflict must fail'); } catch (DomainException) { self::assertSame($before, $this->fileRows()); self::assertFalse($this->pdo->inTransaction()); } } public static function associationConflicts(): array { return array_map(static fn (string $case): array => [$case], [ 'missing-expected', 'missing-target', 'missing-file', 'null-expected', 'stale-expected', 'mixed-owner', ]); } public function testNestedAssociationSuccessRemainsOwnedByCaller(): void { $service = new \MeshVault\Catalog\CatalogService($this->catalog); $model = $service->createModel('/', 'Target'); $file = $this->catalog->recordFile(null, 'one.stl', 'hash', 1); $before = $this->fileRows(); $this->pdo->beginTransaction(); $service->assignFiles($model, [$file]); self::assertTrue($this->pdo->inTransaction()); self::assertSame($model, (int) $this->fileRows()[0]['model_id']); $this->pdo->rollBack(); self::assertSame($before, $this->fileRows()); } #[DataProvider('failureTransactions')] public function testLaterUpdateFailureRollsBackEarlierUpdates(bool $nested, bool $throughService): void { $service = new \MeshVault\Catalog\CatalogService($this->catalog); $model = $service->createModel('/', 'Target'); $one = $this->catalog->recordFile(null, 'one.stl', 'one', 1); $two = $this->catalog->recordFile(null, 'two.stl', 'two', 2); $before = $this->fileRows(); if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') { $this->pdo->exec('CREATE FUNCTION reject_later_update() RETURNS trigger LANGUAGE plpgsql AS $$' . ' BEGIN IF NEW.id = ' . $two . ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model . " THEN RAISE EXCEPTION 'later update rejected'; END IF; RETURN NEW; END; $$"); $this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files' . ' FOR EACH ROW EXECUTE FUNCTION reject_later_update()'); } else { $this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files WHEN NEW.id = ' . $two . ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model . " BEGIN SELECT RAISE(ABORT, 'later update rejected'); END"); } if ($nested) { $this->pdo->beginTransaction(); $outer = $service->createModel('/outer', 'Outer'); } try { if ($throughService) { $service->assignFiles($model, [$two, $one]); } else { $this->catalog->changeFileOwner([$two, $one], null, $model); } self::fail('Later SQL update must fail'); } catch (\PDOException $error) { self::assertStringContainsString('later update rejected', $error->getMessage()); } self::assertSame($before, $this->fileRows()); self::assertSame($nested, $this->pdo->inTransaction()); if ($nested) { self::assertNotNull($service->aggregate($outer)); $this->pdo->commit(); self::assertNotNull($service->aggregate($outer)); } } public static function failureTransactions(): array { return [[false, false], [true, false], [false, true], [true, true]]; } public function testServiceAssociationsPreservePhysicalFilesAndMetadata(): void { $service = new \MeshVault\Catalog\CatalogService($this->catalog); $first = $service->createModel('/home', 'Multipart'); $second = $service->createModel('/other-home', 'Destination'); $root = dirname(__DIR__, 2) . '/.superpowers/catalog-core/files-' . bin2hex(random_bytes(6)); mkdir($root, 0777, true); $paths = ['parts/one.stl', 'parts/two.stl', 'images/selected.png', 'docs/selected.pdf', 'docs/unselected.pdf']; $ids = $physical = []; try { foreach ($paths as $path) { if (!is_dir(dirname($root . '/' . $path))) { mkdir(dirname($root . '/' . $path)); } $bytes = "fixture\0" . $path; file_put_contents($root . '/' . $path, $bytes); touch($root . '/' . $path, 1000000000); $physical[$path] = [file_get_contents($root . '/' . $path), filemtime($root . '/' . $path)]; $ids[] = $this->catalog->recordFile(null, $path, hash('sha256', $bytes), strlen($bytes)); } $before = $this->fileRows(); $selected = array_slice($ids, 0, 4); $service->assignFiles($first, array_reverse($selected)); self::assertSame($selected, array_column($service->aggregate($first)['files'], 'id')); $this->catalog->changeFileOwner($selected, $first, $first); $service->reassignFiles($first, $second, $selected); self::assertSame([], $service->aggregate($first)['files']); self::assertSame('/home', $service->aggregate($first)['folder']['path']); self::assertSame('/other-home', $service->aggregate($second)['folder']['path']); self::assertSame($selected, array_column($service->aggregate($second)['files'], 'id')); $after = $this->fileRows(); foreach ($after as $index => $row) { self::assertSame($index < 4 ? $second : null, $row['model_id']); $row['model_id'] = null; self::assertSame($before[$index], $row); } $service->detachFiles($second, $selected); $this->catalog->changeFileOwner($selected, null, null); self::assertSame($before, $this->fileRows()); self::assertSame([], $service->aggregate($second)['files']); self::assertNull($service->aggregate(999999)); foreach ($physical as $path => $snapshot) { clearstatcache(true, $root . '/' . $path); self::assertSame($snapshot, [ file_get_contents($root . '/' . $path), filemtime($root . '/' . $path), ]); } } finally { foreach ($paths as $path) { if (is_file($root . '/' . $path)) { unlink($root . '/' . $path); } } foreach (['parts', 'images', 'docs'] as $folder) { if (is_dir($root . '/' . $folder)) { rmdir($root . '/' . $folder); } } rmdir($root); } } public function testContainerResolvesServiceWithSharedRepositoryAndPdo(): void { $config = \MeshVault\Config\AppConfig::fromEnvironment([ 'MESHVAULT_LIBRARY_DIR' => dirname(__DIR__, 2), 'MESHVAULT_DB_HOST' => 'unused', 'MESHVAULT_DB_NAME' => 'unused', 'MESHVAULT_DB_USER' => 'unused', 'MESHVAULT_DB_PASSWORD' => 'unused', ]); $definitions = require dirname(__DIR__, 2) . '/config/container.php'; $builder = new \DI\ContainerBuilder(); $builder->useAutowiring(false); $builder->addDefinitions($definitions($config)); $builder->addDefinitions([PDO::class => $this->pdo]); $container = $builder->build(); self::assertTrue($container->has(\MeshVault\Catalog\CatalogService::class)); $service = $container->get(\MeshVault\Catalog\CatalogService::class); $repository = $container->get(\MeshVault\Repository\CatalogRepositoryInterface::class); self::assertSame($repository, (new \ReflectionObject($service))->getProperty('catalog')->getValue($service)); $this->pdo->beginTransaction(); $model = $service->createModel('/', 'Container'); $file = $repository->recordFile(null, 'container.stl', 'hash', 1); $service->assignFiles($model, [$file]); self::assertCount(1, $service->aggregate($model)['files']); self::assertTrue($this->pdo->inTransaction()); $this->pdo->rollBack(); self::assertNull($service->aggregate($model)); } private function fileRows(): array { return $this->pdo->query('SELECT * FROM files ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); } private function countRows(string $table): int { return (int) $this->pdo->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn(); } }