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.
434 lines
20 KiB
PHP
434 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MeshVault\Tests\Integration;
|
|
|
|
use DomainException;
|
|
use InvalidArgumentException;
|
|
use MeshVault\Catalog\CatalogService;
|
|
use MeshVault\Catalog\CatalogServiceInterface;
|
|
use MeshVault\Repository\PdoCatalogRepository;
|
|
use PDO;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
use RuntimeException;
|
|
|
|
final class CatalogContractsTest extends TestCase
|
|
{
|
|
private PDO $pdo;
|
|
private PdoCatalogRepository $repository;
|
|
private CatalogService $service;
|
|
private string $database;
|
|
private string $physicalRoot;
|
|
|
|
private function physicalFiles(?int $owner): array
|
|
{
|
|
$this->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'],
|
|
]);
|
|
}
|
|
}
|