physicalRoot = RepositoryDatabase::directory('contract-files'); $ids = []; foreach (['parts/one.stl', 'parts/two.stl', 'images/photo.png', 'docs/manual.pdf'] as $path) { if (!is_dir(dirname($this->physicalRoot . '/' . $path))) { mkdir(dirname($this->physicalRoot . '/' . $path)); } $bytes = "fixture\0" . $path; file_put_contents($this->physicalRoot . '/' . $path, $bytes); touch($this->physicalRoot . '/' . $path, 1000000000); $ids[] = $this->service->registerFile($owner, $path, hash('sha256', $bytes), strlen($bytes)); } return $ids; } protected function setUp(): void { $this->database = tempnam(sys_get_temp_dir(), 'meshvault-contracts-'); $this->pdo = RepositoryDatabase::migrate($this->database); $this->repository = new PdoCatalogRepository($this->pdo); $this->service = new CatalogService($this->repository); } protected function tearDown(): void { unset($this->service, $this->repository, $this->pdo); gc_collect_cycles(); RepositoryDatabase::cleanup($this->database); if (isset($this->physicalRoot)) { RepositoryDatabase::remove($this->physicalRoot); } } public function testServiceBoundaryAndNormalizedReadOnlyLookups(): void { self::assertInstanceOf(CatalogServiceInterface::class, $this->service); foreach ([$this->repository, $this->service] as $catalog) { self::assertNull($catalog->file(999)); self::assertNull($catalog->fileByManagedPath('absent.stl')); } self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn()); $id = $this->service->registerFile(null, 'physical/nested/a.stl', 'hash', 0); $file = $this->service->file($id); self::assertSame($file, $this->repository->fileByManagedPath('physical/nested/a.stl')); self::assertSame($id, $file['id']); self::assertNull($file['model_id']); self::assertIsInt($file['folder_id']); self::assertSame(0, $file['size']); $this->pdo->exec('UPDATE files SET hash = NULL, size = NULL'); self::assertNull($this->service->file($id)['hash']); self::assertNull($this->service->file($id)['size']); } public function testRegistrationDerivesFoldersAndReplayPreservesEveryColumn(): void { $owner = $this->service->createModel('/home', 'Model'); $id = $this->service->registerFile($owner, 'physical/nested/a.stl', 'hash', 1, 'model/stl', 'Missing'); $before = $this->service->file($id); self::assertSame($owner, $before['model_id']); self::assertSame('/physical/nested', $this->pdo->query( 'SELECT path FROM folders WHERE id = ' . $before['folder_id'] )->fetchColumn()); self::assertSame($id, $this->service->registerFile( $owner, 'physical/nested/a.stl', 'hash', 1, 'other', 'Present' )); self::assertSame($before, $this->service->file($id)); self::assertNotSame($id, $this->service->registerFile(null, 'b.stl', 'hash', 1)); $this->service->detachFiles($owner, [$id]); $this->expectException(DomainException::class); $this->expectExceptionMessage('Managed path already records a different file.'); $this->service->registerFile($owner, 'physical/nested/a.stl', 'hash', 1); } #[DataProvider('invalidRegistration')] public function testDirectRegistrationValidation(array $arguments): void { try { $this->repository->recordFile(...$arguments); self::fail('Malformed registration must fail'); } catch (InvalidArgumentException) { self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn()); } } public function testMissingOwnerIsDomainFailureWithoutFolderWrites(): void { try { $this->repository->recordFile(999, 'new/nested/a.stl', 'hash', 0); self::fail('Missing owner must fail'); } catch (DomainException $error) { self::assertSame('Unknown model.', $error->getMessage()); self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn()); } } public function testNullStoredSizeDoesNotReplayAsZero(): void { $this->repository->recordFile(null, 'a.stl', 'hash', 0); $this->pdo->exec('UPDATE files SET size = NULL'); $this->expectException(DomainException::class); $this->repository->recordFile(null, 'a.stl', 'hash', 0); } public function testAtomicReturnRollbackAndFailurePreservesCallerWrites(): void { self::assertSame('result', $this->service->atomic(static fn (): string => 'result')); $this->pdo->beginTransaction(); $owner = $this->service->createModel('/home', 'Earlier'); $error = new RuntimeException('later failure'); try { $this->service->atomic(function () use ($owner, $error): void { $this->service->registerFile($owner, 'new/nested/a.stl', 'hash', 1); throw $error; }); self::fail('Failure must escape'); } catch (RuntimeException $caught) { self::assertSame($error, $caught); } self::assertTrue($this->pdo->inTransaction()); self::assertNotNull($this->service->aggregate($owner)); self::assertNull($this->service->fileByManagedPath('new/nested/a.stl')); self::assertSame(2, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn()); $id = $this->service->registerFile(null, 'a.stl', 'hash', 1); self::assertTrue($this->pdo->inTransaction()); $this->pdo->rollBack(); self::assertNull($this->service->file($id)); self::assertNull($this->service->aggregate($owner)); } public function testLookupValidationIsEnforcedByRepositoryAndService(): void { foreach ([$this->repository, $this->service] as $catalog) { foreach ( [['file', 0], ['file', -1], ['fileByManagedPath', '../a'], ['fileByManagedPath', "\xff.stl"], ['fileByManagedPath', 'a//b']] as [$method, $value] ) { try { $catalog->$method($value); self::fail('Invalid lookup must fail'); } catch (InvalidArgumentException) { self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn()); } } } } public function testMetadataAndStatusBoundaryExists(): void { foreach ([$this->repository, $this->service] as $catalog) { foreach (['modelMetadata', 'updateModelMetadata', 'changeFileStatus'] as $method) { self::assertTrue(method_exists($catalog, $method), $method); } self::assertFalse(method_exists($catalog, 'setFileStatus')); } } public function testMetadataRawNamedTupleNoopLegacyAndAba(): void { $id = $this->service->createModel('/home', 'T'); $file = $this->service->registerFile($id, 'a.stl', 'hash', 1); $this->pdo->exec("UPDATE models SET notes = '', source_url = 'bad URL', updated_at = '2000-01-01'"); $before = $this->service->aggregate($id); $raw = ['title' => 'T', 'notes' => '', 'sourceURL' => 'bad URL']; self::assertSame($raw, $this->repository->modelMetadata($id)); self::assertSame($raw, $this->service->updateModelMetadata($id, array_reverse($raw, true), $raw)); self::assertSame($before, $this->service->aggregate($id)); $edited = array_replace($raw, ['title' => 'New']); self::assertSame($edited, $this->repository->updateModelMetadata($id, $raw, $edited)); $after = $this->service->aggregate($id); foreach (['id', 'folder_id', 'added_at', 'notes', 'source_url'] as $key) { self::assertSame($before['model'][$key], $after['model'][$key]); } self::assertSame($before['files'], $after['files']); self::assertNotSame($before['model']['updated_at'], $after['model']['updated_at']); $this->service->updateModelMetadata($id, $edited, $raw); self::assertSame($edited, $this->service->updateModelMetadata($id, $raw, $edited)); self::assertSame($file, $after['files'][0]['id']); $cleared = array_replace($edited, ['sourceURL' => null]); self::assertSame($cleared, $this->service->updateModelMetadata($id, $edited, $cleared)); } public function testMetadataStaleBeforeValidationOrNoopAndIndependentFieldsConflict(): void { $id = $this->service->createModel('/', 'T'); $raw = $this->service->modelMetadata($id); $current = array_replace($raw, ['notes' => 'changed']); $this->service->updateModelMetadata($id, $raw, $current); foreach ([$current, array_replace($raw, ['title' => "bad\0"]), $raw] as $replacement) { try { $this->repository->updateModelMetadata($id, $raw, $replacement); self::fail('Stale tuple must fail'); } catch (DomainException $error) { self::assertSame('Model metadata has changed.', $error->getMessage()); } } self::assertSame($current, $this->service->modelMetadata($id)); } public function testEveryStatusPairPreservesOtherColumnsAndNoopTimestamp(): void { $id = $this->service->registerFile(null, 'a.stl', 'hash', 5, 'model/stl'); foreach (['Present', 'Missing', 'Updated', 'Duplicate'] as $from) { foreach (['Present', 'Missing', 'Updated', 'Duplicate'] as $to) { $this->pdo->exec("UPDATE files SET status = '$from', updated_at = '2000-01-01'"); $before = $this->service->file($id); $after = $this->service->changeFileStatus($id, $from, $to); self::assertSame($to, $after['status']); self::assertSame($after, $this->repository->file($id)); if ($from === $to) { self::assertSame($before, $after); } else { self::assertNotSame($before['updated_at'], $after['updated_at']); } unset($before['status'], $before['updated_at'], $after['status'], $after['updated_at']); self::assertSame($before, $after); } } $this->expectException(DomainException::class); $this->expectExceptionMessage('File status has changed.'); $this->repository->changeFileStatus($id, 'Present', 'Duplicate'); } public function testCreationRejectsNulThroughBothPaths(): void { foreach (['createModel', 'ensureModel'] as $method) { try { $this->repository->$method('/', "bad\0title"); self::fail('NUL must fail'); } catch (InvalidArgumentException) { self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM models')->fetchColumn()); } } } public function testMutationValidationAndMissingIdentities(): void { $tuple = ['title' => 'T', 'notes' => null, 'sourceURL' => null]; foreach ([$this->repository, $this->service] as $catalog) { self::assertNull($catalog->modelMetadata(999)); $invalid = [ ['modelMetadata', [0]], ['modelMetadata', [-1]], ['updateModelMetadata', [0, $tuple, $tuple]], ['updateModelMetadata', [999, [], $tuple]], ['updateModelMetadata', [999, $tuple, []]], ['changeFileStatus', [0, 'Present', 'Present']], ['changeFileStatus', [999, 'invalid', 'Present']], ['changeFileStatus', [999, 'Present', 'invalid']], ]; foreach ($invalid as [$method, $args]) { try { $catalog->$method(...$args); self::fail('Invalid input must fail'); } catch (InvalidArgumentException) { self::assertNull($catalog->modelMetadata(999)); } } foreach ( [['updateModelMetadata', [999, $tuple, $tuple], 'Unknown model.'], ['changeFileStatus', [999, 'Present', 'Present'], 'Unknown file.']] as [$method, $args, $message] ) { try { $catalog->$method(...$args); self::fail('Missing identity must fail'); } catch (DomainException $error) { self::assertSame($message, $error->getMessage()); } } } } public function testMetadataEffectiveNoopAndRawNullDistinction(): void { $id = $this->service->createModel('/', 'T'); $this->pdo->exec("UPDATE models SET updated_at = '2000-01-01'"); $raw = $this->service->modelMetadata($id); $before = $this->service->aggregate($id); self::assertSame($raw, $this->service->updateModelMetadata( $id, $raw, array_replace($raw, ['notes' => '', 'sourceURL' => '']) )); self::assertSame($before, $this->service->aggregate($id)); $this->pdo->exec("UPDATE models SET source_url = ''"); try { $this->service->updateModelMetadata($id, $raw, $raw); self::fail('Raw null must not match empty string'); } catch (DomainException $error) { self::assertSame('Model metadata has changed.', $error->getMessage()); } $raw = $this->service->modelMetadata($id); self::assertSame('', $this->service->updateModelMetadata($id, $raw, $raw)['sourceURL']); self::assertNull($this->service->updateModelMetadata( $id, $raw, array_replace($raw, ['sourceURL' => null]) )['sourceURL']); } public function testPhysicalSnapshotsSurviveMutationsNoopsAndRejectedChanges(): void { $owner = $this->service->createModel('/', 'Multipart'); $other = $this->service->createModel('/other', 'Other'); $ids = $this->physicalFiles($owner); $snapshot = PhysicalFileSnapshot::capture($this->physicalRoot); $raw = $this->service->modelMetadata($owner); $edited = array_replace($raw, ['title' => 'Edited', 'notes' => 'Notes']); $operations = [ fn () => $this->service->updateModelMetadata($owner, $raw, $edited), fn () => $this->service->updateModelMetadata($owner, $edited, $edited), fn () => $this->service->changeFileStatus($ids[0], 'Present', 'Missing'), fn () => $this->service->changeFileStatus($ids[0], 'Missing', 'Missing'), fn () => $this->service->reassignFiles($owner, $other, $ids), fn () => $this->repository->changeFileOwner($ids, $other, $other), fn () => $this->service->detachFiles($other, $ids), fn () => $this->repository->changeFileOwner($ids, null, null), fn () => $this->service->assignFiles($owner, $ids), ]; foreach ($operations as $index => $operation) { $operation(); self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot), "Mutation $index"); } $before = $this->service->aggregate($owner); $rejections = [ fn () => $this->service->updateModelMetadata($owner, $raw, $edited), fn () => $this->service->updateModelMetadata($owner, $edited, array_replace($edited, ['title' => ''])), fn () => $this->service->changeFileStatus($ids[0], 'Present', 'Missing'), fn () => $this->service->changeFileStatus($ids[0], 'Missing', 'invalid'), fn () => $this->service->reassignFiles($other, $owner, $ids), ]; foreach ($rejections as $index => $operation) { try { $operation(); self::fail('Expected rejected mutation'); } catch (DomainException | InvalidArgumentException) { self::assertSame($before, $this->service->aggregate($owner)); self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot), "Rejection $index"); } } } public function testMutationsRollbackWithCallerAndLaterSqlFailure(): void { $id = $this->service->createModel('/', 'T'); $files = $this->physicalFiles($id); $file = $files[0]; $snapshot = PhysicalFileSnapshot::capture($this->physicalRoot); $raw = $this->service->modelMetadata($id); $new = array_replace($raw, ['title' => 'New']); $before = $this->service->file($file); $this->pdo->beginTransaction(); $earlier = $this->service->createModel('/', 'Earlier'); $postgres = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) === 'pgsql'; if ($postgres) { $this->pdo->exec('ALTER TABLE files ADD CONSTRAINT "forced later write" CHECK (status <> \'Missing\')'); } else { $this->pdo->exec("CREATE TRIGGER fail_status BEFORE UPDATE OF status ON files BEGIN SELECT RAISE(ABORT, 'forced later write'); END"); } try { $this->service->atomic(function () use ($id, $file, $files, $raw, $new): void { $this->service->detachFiles($id, $files); $this->service->updateModelMetadata($id, $raw, $new); $this->repository->changeFileStatus($file, 'Present', 'Missing'); }); self::fail('SQL failure must escape'); } catch (\PDOException $error) { self::assertStringContainsString('forced later write', $error->getMessage()); } self::assertTrue($this->pdo->inTransaction()); self::assertNotNull($this->service->aggregate($earlier)); self::assertSame($raw, $this->service->modelMetadata($id)); self::assertSame($before, $this->service->file($file)); self::assertSame($files, array_column($this->service->aggregate($id)['files'], 'id')); self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot)); $this->pdo->exec($postgres ? 'ALTER TABLE files DROP CONSTRAINT "forced later write"' : 'DROP TRIGGER fail_status'); $this->service->updateModelMetadata($id, $raw, $new); $this->service->changeFileStatus($file, 'Present', 'Missing'); $this->service->detachFiles($id, $files); self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot)); self::assertTrue($this->pdo->inTransaction()); $this->pdo->rollBack(); self::assertSame($raw, $this->service->modelMetadata($id)); self::assertSame($before, $this->service->file($file)); self::assertNull($this->service->aggregate($earlier)); self::assertSame($files, array_column($this->service->aggregate($id)['files'], 'id')); self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot)); } public static function invalidRegistration(): array { return array_map(static fn (array $args): array => [$args], [ [null, 'a.stl', '', 0], [null, 'a.stl', str_repeat('a', 65), 0], [null, 'a.stl', "bad\0hash", 0], [null, 'a.stl', "\xff", 0], [null, 'a.stl', 'hash', -1], [0, 'a.stl', 'hash', 0], [null, 'a.stl', 'hash', 0, str_repeat('a', 129)], [null, 'a.stl', 'hash', 0, "bad\0mime"], [null, 'a.stl', 'hash', 0, "\xff"], [null, "\xff.stl", 'hash', 0], [null, '../a.stl', 'hash', 0], [null, 'a.stl', 'hash', 0, null, 'invalid'], ]); } }