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.
This commit is contained in:
Cline committed 2026-10-13 19:41:03 -05:00
1 parent 6b9fb54b4f
commit a829d6e8c6
21 files changed
+1903 -61

No files matched your search

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use ArrayObject;
use PDOStatement;
/**
* Observe prepared-statement execute attempts, including failed executions.
* Does not observe PDO query/exec calls or implicit SQL; retains real locks/results.
*/
final class CatalogContractsStatement extends PDOStatement
{
protected function __construct(private ArrayObject $trace)
{
}
public function execute(?array $params = null): bool
{
$this->trace[] = $this->queryString;
return parent::execute($params);
}
}
+433
View File
@@ -0,0 +1,433 @@
<?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'],
]);
}
}
+112 -6
View File
@@ -57,7 +57,7 @@ final class ImportPipelineTest extends TestCase
return new ImportService(
new FileEnumerator(new LocalFilesystem(), $safety),
$this->jobs,
$this->catalog,
new \MeshVault\Indexer\LegacyGuessedImportGroupingAdapter($this->catalog),
$space ?? new SpaceChecker(),
$this->library,
);
@@ -69,7 +69,7 @@ final class ImportPipelineTest extends TestCase
$sourceSafety = new PathSafety($this->source);
return new ImportWorker(
$this->jobs,
$this->catalog,
new \MeshVault\Catalog\CatalogService($this->catalog),
new StreamingHasher($sourceSafety),
new VerifiedCopier($safety),
new StlValidator($sourceSafety),
@@ -93,7 +93,113 @@ final class ImportPipelineTest extends TestCase
return $path;
}
public function testImportCreatesJobWithExplicitSubfolderGrouping(): void
public function testNullOwnerIsRegisteredAndScanFailuresAreNot(): void
{
$source = $this->writeStl('loose.stl');
$job = $this->jobs->create($this->source, [
['source_path' => $source, 'size' => filesize($source), 'model_id' => null],
['source_path' => 'unreadable', 'size' => 0, 'model_id' => null,
'status' => 'Failed', 'failure_reason' => 'SCAN_READ_ERROR'],
]);
$this->worker()->runOnce();
$files = $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC);
self::assertCount(1, $files);
self::assertNull($files[0]['model_id']);
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
}
public function testRegistrationConflictReportsCatalogConflictAndPreservesBytes(): void
{
$source = $this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
$other = $this->catalog->ensureModel('/other', 'other');
$id = $this->catalog->recordFile($other, 'planes/wing.stl', hash_file('sha256', $source), filesize($source));
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
$this->worker()->runOnce();
$item = $this->jobs->items($job)[0];
self::assertSame('Failed', $item['status']);
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
self::assertSame($other, (int) $this->catalog->file($id)['model_id']);
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
self::assertSame(
$sourceBefore['planes/wing.stl']['bytes'],
file_get_contents($this->library . '/planes/wing.stl')
);
}
public function testCompletionRegistersBeforeUpdatingItemPath(): void
{
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
self::markTestSkipped('SQLite ordering assertion trigger.');
}
$this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
$this->pdo->exec("CREATE TRIGGER catalog_first BEFORE UPDATE OF managed_path ON import_items"
. " WHEN OLD.hash IS NOT NULL AND NOT EXISTS (SELECT 1 FROM files WHERE managed_path = NEW.managed_path)"
. " BEGIN SELECT RAISE(ABORT, 'catalog must precede progress'); END");
$this->worker()->runOnce();
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
}
public function testMissingNonNullOwnerFailsRatherThanBecomingUnassigned(): void
{
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
self::markTestSkipped('SQLite fixture deliberately simulates a stale manifest owner.');
}
$source = $this->writeStl('loose.stl');
$this->pdo->exec('PRAGMA foreign_keys = OFF');
$job = $this->jobs->create($this->source, [
['source_path' => $source, 'size' => filesize($source), 'model_id' => 999999],
]);
$this->pdo->exec('PRAGMA foreign_keys = ON');
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
$this->worker()->runOnce();
$item = $this->jobs->items($job)[0];
self::assertSame('Failed', $item['status']);
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
self::assertSame($sourceBefore['loose.stl']['bytes'], file_get_contents($this->library . '/unfiled/loose.stl'));
}
public function testCompletionSqlFailureRollsBackCatalogPathAndProgressInsideCallerTransaction(): void
{
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
self::markTestSkipped('SQLite failure-injection trigger.');
}
$source = $this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
mkdir($this->library . '/planes');
file_put_contents($this->library . '/planes/wing.stl', 'different bytes');
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
$libraryBefore = PhysicalFileSnapshot::capture($this->library);
// Fail after registration and item-path update, during progress refresh.
$this->pdo->exec("CREATE TRIGGER reject_completion BEFORE UPDATE ON import_jobs"
. " WHEN NEW.status = 'Completed' BEGIN SELECT RAISE(ABORT, 'injected completion failure'); END");
$this->pdo->beginTransaction();
$sentinel = $this->catalog->createModel('/sentinel', 'sentinel');
$this->worker()->runOnce();
self::assertTrue($this->pdo->inTransaction());
self::assertNotNull($this->catalog->aggregate($sentinel));
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
$item = $this->jobs->items($job)[0];
self::assertSame('planes/wing.stl', $item['managed_path']);
self::assertSame('Failed', $item['status']);
self::assertStringStartsWith('COPY_FAILED:', $item['failure_reason']);
self::assertSame('Failed', $this->jobs->get($job)['status']);
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
$published = PhysicalFileSnapshot::capture($this->library);
self::assertSame($libraryBefore['planes/wing.stl'], $published['planes/wing.stl']);
self::assertSame($sourceBefore['planes/wing.stl']['bytes'], $published['planes/wing_001.stl']['bytes']);
self::assertSame($sourceBefore['planes/wing.stl']['size'], $published['planes/wing_001.stl']['size']);
$this->pdo->rollBack();
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
self::assertSame($published, PhysicalFileSnapshot::capture($this->library));
self::assertNull($this->catalog->aggregate($sentinel));
self::assertSame('Pending', $this->jobs->items($job)[0]['status']);
}
public function testImportCreatesJobWithLegacyGuessedSubfolderGrouping(): void
{
$this->writeStl('planes/wing.stl');
$this->writeStl('planes/body.stl');
@@ -108,7 +214,7 @@ final class ImportPipelineTest extends TestCase
$aggregate = $this->catalog->aggregate((int) $item['model_id']);
$models[$aggregate['model']['name']] = $aggregate['folder']['path'];
}
// Explicit policy: one model per immediate source subfolder; root files join
// Legacy guessed grouping: one model per immediate source subfolder; root files join
// a model named after the source directory itself.
self::assertSame('/planes', $models['planes']);
self::assertSame('/' . basename($this->source), $models[basename($this->source)]);
@@ -353,7 +459,7 @@ final class ImportPipelineTest extends TestCase
$sourceSafety = new PathSafety($this->source);
$worker = new ImportWorker(
$this->jobs,
$this->catalog,
new \MeshVault\Catalog\CatalogService($this->catalog),
new StreamingHasher($sourceSafety),
new VerifiedCopier($safety),
new StlValidator($sourceSafety),
@@ -390,7 +496,7 @@ final class ImportPipelineTest extends TestCase
$sourceSafety = new PathSafety($this->source);
$worker = new ImportWorker(
$this->jobs,
$this->catalog,
new \MeshVault\Catalog\CatalogService($this->catalog),
new StreamingHasher($sourceSafety),
new VerifiedCopier($safety),
new StlValidator($sourceSafety),
@@ -0,0 +1,192 @@
<?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;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
/** Sorted relative paths and physical contents/stat data; never catalog row data. */
final class PhysicalFileSnapshot
{
public static function capture(string $root): array
{
$snapshot = [];
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(
$root,
\FilesystemIterator::SKIP_DOTS
));
foreach ($files as $file) {
$path = $file->getPathname();
clearstatcache(true, $path);
$relative = str_replace('\\', '/', substr($path, strlen($root) + 1));
$snapshot[$relative] = [
'bytes' => file_get_contents($path),
'size' => filesize($path),
'mtime' => filemtime($path),
];
}
ksort($snapshot);
return $snapshot;
}
}
@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use DomainException;
use MeshVault\Repository\PdoCatalogRepository;
use PDO;
use PDOException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class PostgresCatalogContractsTest extends TestCase
{
private string $database;
private PDO $a;
private PDO $b;
private PdoCatalogRepository $first;
private PdoCatalogRepository $second;
protected function setUp(): void
{
if (!RepositoryDatabase::postgresConfigured()) {
self::markTestSkipped('Requires isolated PostgreSQL.');
}
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-contracts-');
$this->a = RepositoryDatabase::migrate($this->database);
$this->b = RepositoryDatabase::connect($this->database);
foreach ([$this->a, $this->b] as $pdo) {
$pdo->exec("SET lock_timeout = '250ms'");
$pdo->exec("SET statement_timeout = '5s'");
}
self::assertNotSame(
$this->a->query('SELECT pg_backend_pid()')->fetchColumn(),
$this->b->query('SELECT pg_backend_pid()')->fetchColumn(),
);
self::assertSame(
$this->a->query('SELECT current_schema()')->fetchColumn(),
$this->b->query('SELECT current_schema()')->fetchColumn(),
);
$this->first = new PdoCatalogRepository($this->a);
$this->second = new PdoCatalogRepository($this->b);
}
protected function tearDown(): void
{
foreach (['a', 'b'] as $property) {
if (isset($this->$property) && $this->$property->inTransaction()) {
$this->$property->rollBack();
}
}
unset($this->first, $this->second, $this->a, $this->b);
if (isset($this->database)) {
RepositoryDatabase::cleanup($this->database);
}
}
private function timeout(callable $operation): void
{
try {
$operation();
self::fail('Expected PostgreSQL lock timeout; a replay/no-op must not escape the lock.');
} catch (PDOException $error) {
self::assertSame('55P03', $error->getCode());
self::assertStringContainsString('lock timeout', $error->getMessage());
}
}
private function conflict(callable $operation, string $message): void
{
try {
$operation();
self::fail('Expected stale state rejection.');
} catch (DomainException $error) {
self::assertSame($message, $error->getMessage());
}
}
public static function ownershipCases(): iterable
{
foreach (['assign', 'reassign', 'detach', 'noop', 'null-noop'] as $kind) {
foreach ([true, false] as $commit) {
yield $kind . ($commit ? '-commit' : '-rollback') => [$kind, $commit];
}
}
}
#[DataProvider('ownershipCases')]
public function testRegistrationAgainstOwnershipAndReplayRetention(string $kind, bool $commit): void
{
$source = $this->first->createModel('/home', 'Source');
$target = $this->first->createModel('/home', 'Target');
$old = in_array($kind, ['assign', 'null-noop'], true) ? null : $source;
$next = match ($kind) {
'detach', 'null-noop' => null,
'noop' => $source,
default => $target,
};
$file = $this->first->recordFile($old, 'physical/nested/part.stl', 'hash', 1);
$this->a->beginTransaction();
$this->first->changeFileOwner([$file], $old, $next);
$this->timeout(fn () => $this->second->recordFile($old, 'physical/nested/part.stl', 'hash', 1));
$commit ? $this->a->commit() : $this->a->rollBack();
$current = $commit ? $next : $old;
if ($current !== $old) {
$this->conflict(
fn () => $this->second->recordFile($old, 'physical/nested/part.stl', 'hash', 1),
'Managed path already records a different file.',
);
}
$this->b->beginTransaction();
self::assertSame($file, $this->second->recordFile($current, 'physical/nested/part.stl', 'hash', 1));
$this->timeout(fn () => $this->first->changeFileOwner([$file], $current, $current));
$this->timeout(fn () => $this->first->createModel('/unrelated', 'Root retained'));
$this->b->commit();
$this->first->changeFileOwner([$file], $current, $current);
self::assertSame($current, $this->first->file($file)['model_id']);
}
#[DataProvider('ownershipCases')]
public function testMetadataKeyShareComposition(string $kind, bool $commit): void
{
// Reverse backend roles on rollback as well as exercising both transaction outcomes.
[$a, $b, $first, $second] = $commit
? [$this->a, $this->b, $this->first, $this->second]
: [$this->b, $this->a, $this->second, $this->first];
$model = $first->createModel('/home', 'Metadata target');
$source = $first->createModel('/home', 'Other owner');
$owner = match ($kind) {
'assign', 'null-noop' => null,
'noop', 'detach' => $model,
default => $source,
};
$target = in_array($kind, ['detach', 'null-noop'], true) ? null : $model;
$file = $first->recordFile($owner, 'physical/part.stl', 'hash', 1);
$old = $first->modelMetadata($model);
$new = array_replace($old, ['notes' => 'metadata']);
$a->beginTransaction();
$first->updateModelMetadata($model, $old, $new);
$b->beginTransaction();
$second->changeFileOwner([$file], $owner, $owner);
// B holds the file, A retains metadata after its operation savepoint times out.
$this->timeout(fn () => $first->changeFileOwner([$file], $owner, $target));
// Real FK acquisition for assign/reassign, not a simulated KEY SHARE query.
$second->changeFileOwner([$file], $owner, $target);
$this->timeout(fn () => $second->updateModelMetadata($model, $old, $old));
$commit ? $b->commit() : $b->rollBack();
$current = $commit ? $target : $owner;
$first->changeFileOwner([$file], $current, $target);
self::assertSame($new, $first->modelMetadata($model));
self::assertSame($target, $first->file($file)['model_id']);
$a->commit();
self::assertSame($new, $second->modelMetadata($model));
self::assertSame($target, $second->file($file)['model_id']);
}
public function testIndependentStatusOwnershipAndNoUnnecessaryRootLocks(): void
{
$model = $this->first->createModel('/home', 'Model');
$file = $this->first->recordFile($model, 'physical/part.stl', 'hash', 1);
$old = $this->first->modelMetadata($model);
$this->a->beginTransaction();
$this->a->exec("UPDATE folders SET name = name WHERE path = '/'");
$this->second->updateModelMetadata($model, $old, $old);
$this->second->changeFileStatus($file, 'Present', 'Missing');
$this->second->changeFileOwner([$file], $model, null);
$this->a->rollBack();
$this->b->beginTransaction();
$before = $this->second->file($file);
self::assertSame($before, $this->second->changeFileStatus($file, 'Missing', 'Missing'));
$this->timeout(fn () => $this->first->changeFileOwner([$file], null, $model));
$this->b->commit();
$this->first->changeFileOwner([$file], null, $model);
self::assertSame($file, $this->first->recordFile($model, 'physical/part.stl', 'hash', 1));
self::assertSame('Missing', $this->first->file($file)['status']);
self::assertSame($model, $this->first->file($file)['model_id']);
self::assertSame($before['updated_at'], $this->first->file($file)['updated_at']);
$this->b->beginTransaction();
$this->second->updateModelMetadata($model, $old, $old);
$this->timeout(fn () => $this->first->updateModelMetadata($model, $old, $old));
$this->b->rollBack();
self::assertSame($old, $this->first->updateModelMetadata($model, $old, $old));
}
#[DataProvider('outcomes')]
public function testNewPathSerializationAndPhysicalFolders(bool $commit): void
{
$model = $this->first->createModel('/organizational', 'Model');
$this->a->beginTransaction();
$file = $this->first->recordFile($model, 'physical/nested/part.stl', 'hash', 1);
$this->timeout(fn () => $this->second->recordFile(null, 'physical/nested/part.stl', 'hash', 1));
self::assertSame(0, (int) $this->b->query('SELECT COUNT(*) FROM files')->fetchColumn());
$commit ? $this->a->commit() : $this->a->rollBack();
if ($commit) {
$this->conflict(
fn () => $this->second->recordFile(null, 'physical/nested/part.stl', 'hash', 1),
'Managed path already records a different file.',
);
self::assertSame($file, $this->second->recordFile($model, 'physical/nested/part.stl', 'hash', 1));
} else {
self::assertSame(0, (int) $this->b->query(
"SELECT COUNT(*) FROM folders WHERE path LIKE '/physical%'",
)->fetchColumn());
$file = $this->second->recordFile(null, 'physical/nested/part.stl', 'hash', 1);
}
self::assertSame(1, (int) $this->b->query('SELECT COUNT(*) FROM files')->fetchColumn());
$folder = $this->second->file($file)['folder_id'];
self::assertSame('/physical/nested', $this->b->query(
'SELECT path FROM folders WHERE id = ' . $folder,
)->fetchColumn());
self::assertNotSame((int) $this->second->aggregate($model)['model']['folder_id'], $folder);
}
public function testRegistrationTraceAndSqlFailureRollback(): void
{
$model = $this->first->createModel('/home', 'Model');
$trace = new \ArrayObject();
$this->a->setAttribute(PDO::ATTR_STATEMENT_CLASS, [CatalogContractsStatement::class, [$trace]]);
$file = $this->first->recordFile($model, 'physical/nested/part.stl', 'hash', 1);
$sql = $trace->getArrayCopy();
$insert = array_key_last($sql);
self::assertStringContainsString('INSERT INTO files', $sql[$insert]);
self::assertStringContainsString('SELECT id FROM folders', $sql[$insert - 1]);
foreach ([false, true] as $conflict) {
$trace->exchangeArray([]);
if ($conflict) {
$this->conflict(
fn () => $this->first->recordFile(null, 'physical/nested/part.stl', 'hash', 1),
'Managed path already records a different file.',
);
} else {
self::assertSame($file, $this->first->recordFile($model, 'physical/nested/part.stl', 'hash', 1));
}
$sql = $trace->getArrayCopy();
self::assertCount(4, $sql);
self::assertSame("UPDATE folders SET name = name WHERE path = '/'", $sql[2]);
self::assertSame('SELECT * FROM files WHERE managed_path = ? FOR UPDATE', $sql[3]);
}
$this->a->exec("ALTER TABLE files ADD CONSTRAINT reject_test_hash CHECK (hash <> 'rejected')");
$this->a->beginTransaction();
$this->a->exec("INSERT INTO tags (name) VALUES ('sentinel')");
try {
$this->first->recordFile(null, 'rollback/nested/part.stl', 'rejected', 1);
self::fail('Expected actual SQL constraint failure.');
} catch (PDOException $error) {
self::assertSame('23514', $error->getCode());
}
self::assertTrue($this->a->inTransaction());
self::assertSame('sentinel', $this->a->query('SELECT name FROM tags')->fetchColumn());
self::assertSame(0, (int) $this->a->query(
"SELECT COUNT(*) FROM folders WHERE path LIKE '/rollback%'",
)->fetchColumn());
// The operation's root lock was released, while earlier caller work remains uncommitted.
$this->second->createModel('/after-failure', 'Independent');
self::assertSame(0, (int) $this->b->query('SELECT COUNT(*) FROM tags')->fetchColumn());
$this->a->commit();
self::assertSame('sentinel', $this->b->query('SELECT name FROM tags')->fetchColumn());
}
public static function outcomes(): array
{
return ['commit' => [true], 'rollback' => [false]];
}
#[DataProvider('outcomes')]
public function testMetadataAndStatusStaleEditsIncludingEqualTarget(bool $commit): void
{
$model = $this->first->createModel('/home', 'Original');
$file = $this->first->recordFile($model, 'physical/part.stl', 'hash', 1);
$old = $this->first->modelMetadata($model);
$new = array_replace($old, ['notes' => 'winner']);
$this->a->beginTransaction();
$this->first->updateModelMetadata($model, $old, $new);
$this->first->changeFileStatus($file, 'Present', 'Missing');
$this->b->beginTransaction();
$this->b->exec("INSERT INTO tags (name) VALUES ('caller survives')");
foreach ([$old, $new] as $replacement) {
$this->timeout(fn () => $this->second->updateModelMetadata($model, $old, $replacement));
}
foreach (['Present', 'Missing'] as $target) {
$this->timeout(fn () => $this->second->changeFileStatus($file, 'Present', $target));
}
self::assertTrue($this->b->inTransaction());
self::assertSame('caller survives', $this->b->query('SELECT name FROM tags')->fetchColumn());
$commit ? $this->a->commit() : $this->a->rollBack();
if ($commit) {
$this->conflict(
fn () => $this->second->updateModelMetadata($model, $old, $new),
'Model metadata has changed.',
);
$this->conflict(
fn () => $this->second->changeFileStatus($file, 'Present', 'Missing'),
'File status has changed.',
);
} else {
self::assertSame($new, $this->second->updateModelMetadata($model, $old, $new));
self::assertSame('Missing', $this->second->changeFileStatus($file, 'Present', 'Missing')['status']);
}
self::assertTrue($this->b->inTransaction());
$this->b->rollBack();
self::assertSame($commit ? $new : $old, $this->first->modelMetadata($model));
self::assertSame($commit ? 'Missing' : 'Present', $this->first->file($file)['status']);
self::assertSame(0, (int) $this->a->query('SELECT COUNT(*) FROM tags')->fetchColumn());
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ final class RepositoryTest extends TestCase
$file = $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12, 'model/stl');
self::assertSame($file, $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12));
foreach (['Missing', 'Updated', 'Duplicate', 'Present'] as $status) {
$catalog->setFileStatus($file, $status);
$catalog->changeFileStatus($file, $catalog->file($file)['status'], $status);
self::assertSame($status, $catalog->aggregate($model)['files'][0]['status']);
}
$aggregate = $catalog->aggregate($model);
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Catalog;
use InvalidArgumentException;
use MeshVault\Catalog\ModelMetadata;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class ModelMetadataTest extends TestCase
{
public function testContractExists(): void
{
self::assertTrue(class_exists(ModelMetadata::class));
}
#[DataProvider('invalidTitles')]
public function testInvalidTitles(string $title): void
{
$this->expectException(InvalidArgumentException::class);
ModelMetadata::validateTitle($title);
}
public static function invalidTitles(): array
{
return [[''], [" \t\n"], ["a\0b"], ["\xff"], [str_repeat('é', 256)]];
}
public function testPreservesValidAndUnchangedLegacyValues(): void
{
$old = ['title' => '', 'notes' => "\xff", 'sourceURL' => 'not a URL'];
self::assertSame($old, ModelMetadata::replacement($old, $old));
$new = ['title' => ' ' . str_repeat('é', 253) . ' ', 'notes' => "\xff", 'sourceURL' => 'not a URL'];
self::assertSame($new, ModelMetadata::replacement($old, $new));
$old = ['title' => 'T', 'notes' => 'old', 'sourceURL' => 'old'];
self::assertSame(['title' => 'T', 'notes' => null, 'sourceURL' => null], ModelMetadata::replacement(
$old,
['title' => 'T', 'notes' => '', 'sourceURL' => '']
));
ModelMetadata::validateTitle('é');
self::assertSame(" \n", ModelMetadata::replacement($old, array_replace($old, ['notes' => " \n"]))['notes']);
}
#[DataProvider('urls')]
public function testUrls(string $url, bool $valid): void
{
$old = ['title' => 'T', 'notes' => null, 'sourceURL' => null];
if (!$valid) {
$this->expectException(InvalidArgumentException::class);
}
$result = ModelMetadata::replacement($old, array_replace($old, ['sourceURL' => $url]));
self::assertSame($url, $result['sourceURL']);
}
public static function urls(): array
{
$valid = ['https://example.com/a?q=x#f', 'http://127.0.0.1:80/', 'https://[::1]/',
'https://xn--bcher-kva.example/%C3%A9', 'https://example.com/' . str_repeat('a', 2028)];
$invalid = ['relative', 'ftp://example.com', 'https://', 'https://u:p@example.com',
'https://u@example.com', "https://example.com/\n", 'https://example.com/a b',
'https://example.com/\\x', 'https://example.com/%zz', 'https://example.com/%',
'https://example.com/é', 'http://example.com:65536', 'http://example.com:abc',
'https://example.com/' . str_repeat('a', 2030)];
return array_merge(
array_map(static fn ($v) => [$v, true], $valid),
array_map(static fn ($v) => [$v, false], $invalid)
);
}
#[DataProvider('badShapes')]
public function testExactShape(array $shape): void
{
$this->expectException(InvalidArgumentException::class);
ModelMetadata::validateShape($shape);
}
public static function badShapes(): array
{
return [[[]], [['title' => 'T', 'notes' => null]],
[['title' => null, 'notes' => null, 'sourceURL' => null]],
[['title' => 'T', 'notes' => 1, 'sourceURL' => null]],
[['title' => 'T', 'notes' => null, 'sourceURL' => false]],
[['title' => 'T', 'notes' => null, 'sourceURL' => null, 'extra' => null]]];
}
#[DataProvider('badNotes')]
public function testChangedNotesMustBePortable(string $notes): void
{
$old = ['title' => 'T', 'notes' => null, 'sourceURL' => null];
$this->expectException(InvalidArgumentException::class);
ModelMetadata::replacement($old, array_replace($old, ['notes' => $notes]));
}
public static function badNotes(): array
{
return [["\xff"], ["a\0b"]];
}
}
@@ -37,6 +37,38 @@ final class ContainerWiringTest extends TestCase
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
public function testActualFactoryInvocationUsesSharedServiceAndStopCallback(): void
{
$config = AppConfig::fromEnvironment([
'MESHVAULT_LIBRARY_DIR' => dirname(__DIR__, 3), 'MESHVAULT_DB_HOST' => 'unused',
'MESHVAULT_DB_NAME' => 'unused', 'MESHVAULT_DB_USER' => 'unused',
'MESHVAULT_DB_PASSWORD' => 'unused',
]);
$definitions = require dirname(__DIR__, 3) . '/config/container.php';
$builder = new \DI\ContainerBuilder();
$builder->useAutowiring(false);
$builder->addDefinitions($definitions($config));
$builder->addDefinitions([
PDO::class => $this->pdo,
\Psr\Log\LoggerInterface::class => new \Psr\Log\NullLogger(),
]);
$container = $builder->build();
$stop = static fn (): bool => true;
$worker = ($container->get(\MeshVault\Console\ImportWorkerFactory::class))($stop);
$service = $container->get(\MeshVault\Catalog\CatalogServiceInterface::class);
self::assertSame($container->get(\MeshVault\Catalog\CatalogService::class), $service);
$reflection = new \ReflectionObject($worker);
self::assertSame($service, $reflection->getProperty('catalog')->getValue($worker));
self::assertSame($stop, $reflection->getProperty('shouldStop')->getValue($worker));
foreach ([CatalogRepositoryInterface::class, ImportJobRepositoryInterface::class] as $class) {
$repository = $container->get($class);
$property = (new \ReflectionObject($repository))->getProperty('pdo');
self::assertSame($this->pdo, $property->getValue($repository));
}
$import = $container->get(\MeshVault\Indexer\ImportService::class);
self::assertInstanceOf(\MeshVault\Indexer\ImportService::class, $import);
}
public function testRepositoriesShareSamePdoInstance(): void
{
// L5: assert that the container would wire both repositories to the SAME PDO