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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user