feat: establish catalog identity and atomic file ownership
This commit is contained in:
1 parent
390a7a557a
commit
6b9fb54b4f
16 files changed
+1381
-23
No files matched your search
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Integration;
|
||||
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use MeshVault\Repository\PdoCatalogRepository;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class CatalogCoreTest extends TestCase
|
||||
{
|
||||
private PDO $pdo;
|
||||
private PdoCatalogRepository $catalog;
|
||||
private string $database;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-core-');
|
||||
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||
$this->catalog = new PdoCatalogRepository($this->pdo);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($this->catalog, $this->pdo);
|
||||
gc_collect_cycles();
|
||||
RepositoryDatabase::cleanup($this->database);
|
||||
}
|
||||
|
||||
public function testExplicitIdentityAndLegacyCountedLookup(): void
|
||||
{
|
||||
$first = $this->catalog->ensureModel('/parts', ' Plane ');
|
||||
self::assertSame($first, $this->catalog->ensureModel('/parts', ' Plane '));
|
||||
$second = $this->catalog->createModel('/parts', ' Plane ');
|
||||
self::assertNotSame($first, $second);
|
||||
foreach ([$first, $second] as $id) {
|
||||
$aggregate = $this->catalog->aggregate($id);
|
||||
self::assertSame(' Plane ', $aggregate['model']['name']);
|
||||
self::assertSame('/parts', $aggregate['folder']['path']);
|
||||
self::assertSame([], $aggregate['files']);
|
||||
}
|
||||
self::assertNull($this->catalog->aggregate(999999));
|
||||
try {
|
||||
$this->catalog->ensureModel('/parts', ' Plane ');
|
||||
self::fail('Ambiguous legacy lookup must fail');
|
||||
} catch (DomainException) {
|
||||
self::assertSame(2, $this->countRows('models'));
|
||||
}
|
||||
}
|
||||
|
||||
#[DataProvider('invalidModels')]
|
||||
public function testInvalidModelInputsWriteNothing(string $folder, string $title): void
|
||||
{
|
||||
foreach (['createModel', 'ensureModel'] as $method) {
|
||||
try {
|
||||
$this->catalog->$method($folder, $title);
|
||||
self::fail('Invalid model must fail');
|
||||
} catch (InvalidArgumentException) {
|
||||
self::assertSame(0, $this->countRows('models'));
|
||||
self::assertSame(0, $this->countRows('folders'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function invalidModels(): array
|
||||
{
|
||||
return [['', 'Title'], ['/', ''], ['/', " \t\n"], ['/', str_repeat('a', 256)],
|
||||
['/', str_repeat('é', 256)], ['../escape', 'Title']];
|
||||
}
|
||||
|
||||
public function testUnicodeTitleLimitCountsCharactersAndRootIsValid(): void
|
||||
{
|
||||
foreach (['createModel', 'ensureModel'] as $method) {
|
||||
$id = $this->catalog->$method('/', str_repeat('é', 255));
|
||||
self::assertSame(str_repeat('é', 255), $this->catalog->aggregate($id)['model']['name']);
|
||||
self::assertSame('/', $this->catalog->aggregate($id)['folder']['path']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testNullableRegistrationIsIdempotentWithoutChangingMetadata(): void
|
||||
{
|
||||
$id = $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12, 'model/stl', 'Missing');
|
||||
$before = $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC);
|
||||
self::assertNull($before['model_id']);
|
||||
self::assertSame($id, $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12));
|
||||
self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC));
|
||||
$model = $this->catalog->createModel('/elsewhere', 'Title');
|
||||
$owned = $this->catalog->recordFile($model, 'other.stl', 'hash', 12);
|
||||
self::assertSame($owned, $this->catalog->recordFile($model, 'other.stl', 'hash', 12));
|
||||
self::assertSame('/elsewhere', $this->catalog->aggregate($model)['folder']['path']);
|
||||
}
|
||||
|
||||
#[DataProvider('registrationConflicts')]
|
||||
public function testRegistrationNeverAdoptsOrOverwrites(string $conflict): void
|
||||
{
|
||||
$model = $this->catalog->ensureModel('/', 'Title');
|
||||
$owner = $conflict === 'detach' ? $model : null;
|
||||
$this->catalog->recordFile($owner, 'part.stl', 'hash', 12);
|
||||
$before = $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC);
|
||||
try {
|
||||
$this->catalog->recordFile(
|
||||
$conflict === 'adopt' ? $model : null,
|
||||
'part.stl',
|
||||
$conflict === 'hash' ? 'different' : 'hash',
|
||||
$conflict === 'size' ? 13 : 12
|
||||
);
|
||||
self::fail('Conflicting registration must fail');
|
||||
} catch (DomainException) {
|
||||
self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
}
|
||||
|
||||
public static function registrationConflicts(): array
|
||||
{
|
||||
return [['adopt'], ['detach'], ['hash'], ['size']];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidRegistrations')]
|
||||
public function testInvalidRegistrationWritesNothing(?int $owner, string $path, string $hash, int $size): void
|
||||
{
|
||||
try {
|
||||
$this->catalog->recordFile($owner, $path, $hash, $size);
|
||||
self::fail('Invalid registration must fail');
|
||||
} catch (InvalidArgumentException) {
|
||||
self::assertSame(0, $this->countRows('files'));
|
||||
self::assertSame(0, $this->countRows('folders'));
|
||||
}
|
||||
}
|
||||
|
||||
public static function invalidRegistrations(): array
|
||||
{
|
||||
$cases = [];
|
||||
foreach (['', '/absolute', 'a\\b', 'a//b', 'a/./b', 'a/../b', "a\0b", 'https://host/a'] as $path) {
|
||||
$cases[] = [null, $path, 'hash', 1];
|
||||
}
|
||||
return array_merge($cases, [[0, 'a', 'hash', 1], [-1, 'a', 'hash', 1],
|
||||
[null, 'a', '', 1], [null, 'a', 'hash', -1]]);
|
||||
}
|
||||
|
||||
public function testAtomicReturnsValuesAndPreservesCallerTransaction(): void
|
||||
{
|
||||
$this->pdo->beginTransaction();
|
||||
$outer = $this->catalog->ensureModel('/', 'Outer');
|
||||
$id = $this->catalog->atomic(fn () => $this->catalog->createModel('/', 'Inner'));
|
||||
self::assertNotSame($outer, $id);
|
||||
self::assertTrue($this->pdo->inTransaction());
|
||||
try {
|
||||
$this->catalog->atomic(function (): void {
|
||||
$this->catalog->createModel('/rollback', 'Rollback');
|
||||
$this->catalog->recordFile(null, 'rollback/file.stl', 'hash', 1);
|
||||
throw new RuntimeException('abort');
|
||||
});
|
||||
self::fail('Expected failure');
|
||||
} catch (RuntimeException $error) {
|
||||
self::assertSame('abort', $error->getMessage());
|
||||
}
|
||||
self::assertTrue($this->pdo->inTransaction());
|
||||
self::assertSame(2, $this->countRows('models'));
|
||||
self::assertSame(1, $this->countRows('folders'));
|
||||
self::assertSame(0, $this->countRows('files'));
|
||||
$this->pdo->rollBack();
|
||||
self::assertSame(0, $this->countRows('models'));
|
||||
self::assertSame('result', $this->catalog->atomic(static fn () => 'result'));
|
||||
self::assertFalse($this->pdo->inTransaction());
|
||||
}
|
||||
|
||||
#[DataProvider('invalidAssociations')]
|
||||
public function testInvalidAssociationArguments(array $ids, ?int $expected, ?int $target): void
|
||||
{
|
||||
try {
|
||||
$this->catalog->changeFileOwner($ids, $expected, $target);
|
||||
self::fail('Invalid association must fail');
|
||||
} catch (InvalidArgumentException) {
|
||||
self::assertSame(0, $this->countRows('files'));
|
||||
self::assertFalse($this->pdo->inTransaction());
|
||||
}
|
||||
}
|
||||
|
||||
public static function invalidAssociations(): array
|
||||
{
|
||||
return [[[], null, 1], [[1, 1], null, 1], [['1'], null, 1], [[1.0], null, 1],
|
||||
[[true], null, 1], [[null], null, 1], [[0], null, 1], [[-1], null, 1],
|
||||
[[1], 0, 1], [[1], -1, 1], [[1], null, 0], [[1], null, -1], [[2 => 1], null, 1]];
|
||||
}
|
||||
|
||||
#[DataProvider('associationConflicts')]
|
||||
public function testAssociationConflictsPreserveEveryRow(string $case): void
|
||||
{
|
||||
$first = $this->catalog->createModel('/first', 'First');
|
||||
$second = $this->catalog->createModel('/second', 'Second');
|
||||
$one = $this->catalog->recordFile($first, 'one.stl', 'one', 1);
|
||||
$two = $this->catalog->recordFile($case === 'mixed-owner' ? $second : $first, 'two.stl', 'two', 2);
|
||||
$before = $this->fileRows();
|
||||
$expected = match ($case) {
|
||||
'missing-expected' => 999999,
|
||||
'null-expected' => null,
|
||||
'stale-expected' => $second,
|
||||
default => $first,
|
||||
};
|
||||
try {
|
||||
$this->catalog->changeFileOwner(
|
||||
[$one, $case === 'missing-file' ? 999999 : $two],
|
||||
$expected,
|
||||
$case === 'missing-target' ? 999999 : $second
|
||||
);
|
||||
self::fail('Conflict must fail');
|
||||
} catch (DomainException) {
|
||||
self::assertSame($before, $this->fileRows());
|
||||
self::assertFalse($this->pdo->inTransaction());
|
||||
}
|
||||
}
|
||||
|
||||
public static function associationConflicts(): array
|
||||
{
|
||||
return array_map(static fn (string $case): array => [$case], [
|
||||
'missing-expected', 'missing-target', 'missing-file', 'null-expected', 'stale-expected', 'mixed-owner',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testNestedAssociationSuccessRemainsOwnedByCaller(): void
|
||||
{
|
||||
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
|
||||
$model = $service->createModel('/', 'Target');
|
||||
$file = $this->catalog->recordFile(null, 'one.stl', 'hash', 1);
|
||||
$before = $this->fileRows();
|
||||
$this->pdo->beginTransaction();
|
||||
$service->assignFiles($model, [$file]);
|
||||
self::assertTrue($this->pdo->inTransaction());
|
||||
self::assertSame($model, (int) $this->fileRows()[0]['model_id']);
|
||||
$this->pdo->rollBack();
|
||||
self::assertSame($before, $this->fileRows());
|
||||
}
|
||||
|
||||
#[DataProvider('failureTransactions')]
|
||||
public function testLaterUpdateFailureRollsBackEarlierUpdates(bool $nested, bool $throughService): void
|
||||
{
|
||||
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
|
||||
$model = $service->createModel('/', 'Target');
|
||||
$one = $this->catalog->recordFile(null, 'one.stl', 'one', 1);
|
||||
$two = $this->catalog->recordFile(null, 'two.stl', 'two', 2);
|
||||
$before = $this->fileRows();
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
|
||||
$this->pdo->exec('CREATE FUNCTION reject_later_update() RETURNS trigger LANGUAGE plpgsql AS $$'
|
||||
. ' BEGIN IF NEW.id = ' . $two
|
||||
. ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model
|
||||
. " THEN RAISE EXCEPTION 'later update rejected'; END IF; RETURN NEW; END; $$");
|
||||
$this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files'
|
||||
. ' FOR EACH ROW EXECUTE FUNCTION reject_later_update()');
|
||||
} else {
|
||||
$this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files WHEN NEW.id = ' . $two
|
||||
. ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model
|
||||
. " BEGIN SELECT RAISE(ABORT, 'later update rejected'); END");
|
||||
}
|
||||
if ($nested) {
|
||||
$this->pdo->beginTransaction();
|
||||
$outer = $service->createModel('/outer', 'Outer');
|
||||
}
|
||||
try {
|
||||
if ($throughService) {
|
||||
$service->assignFiles($model, [$two, $one]);
|
||||
} else {
|
||||
$this->catalog->changeFileOwner([$two, $one], null, $model);
|
||||
}
|
||||
self::fail('Later SQL update must fail');
|
||||
} catch (\PDOException $error) {
|
||||
self::assertStringContainsString('later update rejected', $error->getMessage());
|
||||
}
|
||||
self::assertSame($before, $this->fileRows());
|
||||
self::assertSame($nested, $this->pdo->inTransaction());
|
||||
if ($nested) {
|
||||
self::assertNotNull($service->aggregate($outer));
|
||||
$this->pdo->commit();
|
||||
self::assertNotNull($service->aggregate($outer));
|
||||
}
|
||||
}
|
||||
|
||||
public static function failureTransactions(): array
|
||||
{
|
||||
return [[false, false], [true, false], [false, true], [true, true]];
|
||||
}
|
||||
|
||||
public function testServiceAssociationsPreservePhysicalFilesAndMetadata(): void
|
||||
{
|
||||
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
|
||||
$first = $service->createModel('/home', 'Multipart');
|
||||
$second = $service->createModel('/other-home', 'Destination');
|
||||
$root = dirname(__DIR__, 2) . '/.superpowers/catalog-core/files-' . bin2hex(random_bytes(6));
|
||||
mkdir($root, 0777, true);
|
||||
$paths = ['parts/one.stl', 'parts/two.stl', 'images/selected.png', 'docs/selected.pdf', 'docs/unselected.pdf'];
|
||||
$ids = $physical = [];
|
||||
try {
|
||||
foreach ($paths as $path) {
|
||||
if (!is_dir(dirname($root . '/' . $path))) {
|
||||
mkdir(dirname($root . '/' . $path));
|
||||
}
|
||||
$bytes = "fixture\0" . $path;
|
||||
file_put_contents($root . '/' . $path, $bytes);
|
||||
touch($root . '/' . $path, 1000000000);
|
||||
$physical[$path] = [file_get_contents($root . '/' . $path), filemtime($root . '/' . $path)];
|
||||
$ids[] = $this->catalog->recordFile(null, $path, hash('sha256', $bytes), strlen($bytes));
|
||||
}
|
||||
$before = $this->fileRows();
|
||||
$selected = array_slice($ids, 0, 4);
|
||||
$service->assignFiles($first, array_reverse($selected));
|
||||
self::assertSame($selected, array_column($service->aggregate($first)['files'], 'id'));
|
||||
$this->catalog->changeFileOwner($selected, $first, $first);
|
||||
$service->reassignFiles($first, $second, $selected);
|
||||
self::assertSame([], $service->aggregate($first)['files']);
|
||||
self::assertSame('/home', $service->aggregate($first)['folder']['path']);
|
||||
self::assertSame('/other-home', $service->aggregate($second)['folder']['path']);
|
||||
self::assertSame($selected, array_column($service->aggregate($second)['files'], 'id'));
|
||||
$after = $this->fileRows();
|
||||
foreach ($after as $index => $row) {
|
||||
self::assertSame($index < 4 ? $second : null, $row['model_id']);
|
||||
$row['model_id'] = null;
|
||||
self::assertSame($before[$index], $row);
|
||||
}
|
||||
$service->detachFiles($second, $selected);
|
||||
$this->catalog->changeFileOwner($selected, null, null);
|
||||
self::assertSame($before, $this->fileRows());
|
||||
self::assertSame([], $service->aggregate($second)['files']);
|
||||
self::assertNull($service->aggregate(999999));
|
||||
foreach ($physical as $path => $snapshot) {
|
||||
clearstatcache(true, $root . '/' . $path);
|
||||
self::assertSame($snapshot, [
|
||||
file_get_contents($root . '/' . $path), filemtime($root . '/' . $path),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
foreach ($paths as $path) {
|
||||
if (is_file($root . '/' . $path)) {
|
||||
unlink($root . '/' . $path);
|
||||
}
|
||||
}
|
||||
foreach (['parts', 'images', 'docs'] as $folder) {
|
||||
if (is_dir($root . '/' . $folder)) {
|
||||
rmdir($root . '/' . $folder);
|
||||
}
|
||||
}
|
||||
rmdir($root);
|
||||
}
|
||||
}
|
||||
|
||||
public function testContainerResolvesServiceWithSharedRepositoryAndPdo(): void
|
||||
{
|
||||
$config = \MeshVault\Config\AppConfig::fromEnvironment([
|
||||
'MESHVAULT_LIBRARY_DIR' => dirname(__DIR__, 2), 'MESHVAULT_DB_HOST' => 'unused',
|
||||
'MESHVAULT_DB_NAME' => 'unused', 'MESHVAULT_DB_USER' => 'unused',
|
||||
'MESHVAULT_DB_PASSWORD' => 'unused',
|
||||
]);
|
||||
$definitions = require dirname(__DIR__, 2) . '/config/container.php';
|
||||
$builder = new \DI\ContainerBuilder();
|
||||
$builder->useAutowiring(false);
|
||||
$builder->addDefinitions($definitions($config));
|
||||
$builder->addDefinitions([PDO::class => $this->pdo]);
|
||||
$container = $builder->build();
|
||||
self::assertTrue($container->has(\MeshVault\Catalog\CatalogService::class));
|
||||
$service = $container->get(\MeshVault\Catalog\CatalogService::class);
|
||||
$repository = $container->get(\MeshVault\Repository\CatalogRepositoryInterface::class);
|
||||
self::assertSame($repository, (new \ReflectionObject($service))->getProperty('catalog')->getValue($service));
|
||||
$this->pdo->beginTransaction();
|
||||
$model = $service->createModel('/', 'Container');
|
||||
$file = $repository->recordFile(null, 'container.stl', 'hash', 1);
|
||||
$service->assignFiles($model, [$file]);
|
||||
self::assertCount(1, $service->aggregate($model)['files']);
|
||||
self::assertTrue($this->pdo->inTransaction());
|
||||
$this->pdo->rollBack();
|
||||
self::assertNull($service->aggregate($model));
|
||||
}
|
||||
|
||||
private function fileRows(): array
|
||||
{
|
||||
return $this->pdo->query('SELECT * FROM files ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function countRows(string $table): int
|
||||
{
|
||||
return (int) $this->pdo->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Integration;
|
||||
|
||||
use ArrayObject;
|
||||
use PDOStatement;
|
||||
|
||||
/** Records execution, including the lock attempt that throws, rather than just SQL preparation. */
|
||||
final class CatalogLockStatement extends PDOStatement
|
||||
{
|
||||
protected function __construct(private ArrayObject $locks)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(?array $params = null): bool
|
||||
{
|
||||
if (preg_match('/SELECT .* FROM files WHERE id = \? FOR UPDATE$/i', $this->queryString)) {
|
||||
$this->locks[] = $params;
|
||||
}
|
||||
return parent::execute($params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Integration;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class CatalogMigrationTest extends TestCase
|
||||
{
|
||||
#[DataProvider('badPaths')]
|
||||
public function testUpgradePreflightLeavesEverythingIntact(string $path, bool $duplicate): void
|
||||
{
|
||||
if ($duplicate) {
|
||||
$this->pdo->exec("INSERT INTO files (id, model_id, folder_id, name, managed_path)
|
||||
VALUES (31, 21, 10, 'other', 'parts/part.stl')");
|
||||
} else {
|
||||
$statement = $this->pdo->prepare('UPDATE files SET managed_path = ? WHERE id = 30');
|
||||
$statement->execute([$path]);
|
||||
}
|
||||
$this->assertPreflightFails(false, $duplicate ? ['30', '31'] : ['30']);
|
||||
}
|
||||
|
||||
public static function badPaths(): array
|
||||
{
|
||||
return [['parts/part.stl', true], ['a/../part.stl', false], ['a//part.stl', false], ['C:/part.stl', false]];
|
||||
}
|
||||
|
||||
#[DataProvider('downgradeConflicts')]
|
||||
public function testDowngradePreflightLeavesEverythingIntact(bool $unassigned): void
|
||||
{
|
||||
RepositoryDatabase::migrateTo($this->path, self::NEW);
|
||||
if ($unassigned) {
|
||||
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
|
||||
} else {
|
||||
$this->pdo->exec("UPDATE models SET name = 'First' WHERE id = 21");
|
||||
}
|
||||
$this->assertPreflightFails(true, $unassigned ? ['30'] : ['20', '21']);
|
||||
}
|
||||
|
||||
public static function downgradeConflicts(): array
|
||||
{
|
||||
return [[true], [false]];
|
||||
}
|
||||
|
||||
private function assertPreflightFails(bool $down, array $ids): void
|
||||
{
|
||||
$before = $this->snapshot();
|
||||
try {
|
||||
if ($down) {
|
||||
RepositoryDatabase::rollbackTo($this->path, self::OLD);
|
||||
} else {
|
||||
RepositoryDatabase::migrateTo($this->path, self::NEW);
|
||||
}
|
||||
self::fail('Expected preflight rejection');
|
||||
} catch (RuntimeException $error) {
|
||||
foreach ($ids as $id) {
|
||||
self::assertStringContainsString($id, $error->getMessage());
|
||||
}
|
||||
self::assertStringContainsString('Resolve', $error->getMessage());
|
||||
}
|
||||
self::assertSame($before, $this->snapshot());
|
||||
}
|
||||
|
||||
private function rows(): array
|
||||
{
|
||||
$rows = [];
|
||||
$tables = ['folders', 'models', 'files', 'tags', 'model_tags', 'thumbnails', 'import_jobs', 'import_items'];
|
||||
foreach ($tables as $table) {
|
||||
$rows[$table] = $this->pdo->query('SELECT * FROM ' . $table . ' ORDER BY 1')->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function indexes(): array
|
||||
{
|
||||
$indexes = [];
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
|
||||
foreach (['files', 'models'] as $table) {
|
||||
foreach ($this->pdo->query('PRAGMA index_list(' . $table . ')')->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$indexes[$table][$row['name']] = (int) $row['unique'];
|
||||
}
|
||||
ksort($indexes[$table]);
|
||||
}
|
||||
}
|
||||
return $indexes;
|
||||
}
|
||||
|
||||
private function snapshot(): array
|
||||
{
|
||||
$snapshot = [$this->rows(), $this->pdo->query('SELECT * FROM phinxlog ORDER BY version')->fetchAll()];
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
|
||||
$snapshot[] = $this->pdo->query('SELECT * FROM sqlite_master ORDER BY type, name')->fetchAll();
|
||||
foreach (['files', 'models'] as $table) {
|
||||
$snapshot[] = $this->pdo->query('PRAGMA foreign_key_list(' . $table . ')')->fetchAll();
|
||||
$snapshot[] = $this->pdo->query('PRAGMA index_list(' . $table . ')')->fetchAll();
|
||||
}
|
||||
}
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
private function assertRejected(string $sql): void
|
||||
{
|
||||
try {
|
||||
$this->pdo->exec($sql);
|
||||
self::fail('Expected database constraint rejection');
|
||||
} catch (PDOException) {
|
||||
self::assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
private const OLD = 20261011120006;
|
||||
private const NEW = 20261012120001;
|
||||
private string $directory;
|
||||
private string $path;
|
||||
private PDO $pdo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->directory = RepositoryDatabase::directory('catalog-migration');
|
||||
$this->path = $this->directory . '/catalog.sqlite';
|
||||
$this->pdo = RepositoryDatabase::migrate($this->path, self::OLD);
|
||||
$this->pdo->exec("INSERT INTO folders (id, name, path) VALUES (10, 'root', '/')");
|
||||
$this->pdo->exec("INSERT INTO models (id, folder_id, name, notes) VALUES
|
||||
(20, 10, 'First', 'keep'), (21, 10, 'Second', 'also keep')");
|
||||
$this->pdo->exec("INSERT INTO files (id, model_id, folder_id, name, managed_path, hash, size)
|
||||
VALUES (30, 20, 10, 'part.stl', 'parts/part.stl', 'abc', 123)");
|
||||
// Explicit fixture IDs do not advance PostgreSQL sequences.
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
|
||||
foreach (['folders' => 10, 'models' => 21, 'files' => 30] as $table => $id) {
|
||||
$this->pdo->query("SELECT setval(pg_get_serial_sequence('$table', 'id'), $id, true)")->closeCursor();
|
||||
}
|
||||
}
|
||||
$this->pdo->exec("INSERT INTO tags (id, name) VALUES (40, 'tag')");
|
||||
$this->pdo->exec('INSERT INTO model_tags (model_id, tag_id) VALUES (20, 40)');
|
||||
$this->pdo->exec("INSERT INTO thumbnails (model_id, managed_path, hash, size, mime_type)
|
||||
VALUES (20, 'thumb.png', 'thumbhash', 12, 'image/png')");
|
||||
$this->pdo->exec("INSERT INTO import_jobs (id, source_path) VALUES (50, 'source')");
|
||||
$this->pdo->exec("INSERT INTO import_items (job_id, source_path, model_id) VALUES (50, 'part.stl', 20)");
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($this->pdo);
|
||||
RepositoryDatabase::cleanup($this->path);
|
||||
RepositoryDatabase::remove($this->directory);
|
||||
}
|
||||
|
||||
public function testRoundTripPreservesRowsAndConstraints(): void
|
||||
{
|
||||
$before = $this->rows();
|
||||
$oldIndexes = $this->indexes();
|
||||
RepositoryDatabase::migrateTo($this->path, self::NEW);
|
||||
$newIndexes = $this->indexes();
|
||||
if ($newIndexes !== []) {
|
||||
self::assertSame(1, $newIndexes['files']['idx_files_managed_path']);
|
||||
self::assertSame(0, $newIndexes['files']['idx_files_model_id']);
|
||||
self::assertSame(0, $newIndexes['models']['idx_models_folder_name']);
|
||||
foreach (['idx_files_hash', 'idx_files_status', 'idx_files_folder_id'] as $index) {
|
||||
self::assertSame($oldIndexes['files'][$index], $newIndexes['files'][$index]);
|
||||
}
|
||||
self::assertSame(
|
||||
$oldIndexes['models']['idx_models_added_at'],
|
||||
$newIndexes['models']['idx_models_added_at']
|
||||
);
|
||||
}
|
||||
self::assertSame($before, $this->rows());
|
||||
$this->assertRejected("INSERT INTO files (model_id, folder_id, name, managed_path)
|
||||
VALUES (NULL, 10, 'duplicate', 'parts/part.stl')");
|
||||
$this->assertRejected('UPDATE files SET model_id = 999 WHERE id = 30');
|
||||
$this->assertRejected('DELETE FROM models WHERE id = 20');
|
||||
$this->assertRejected('DELETE FROM folders WHERE id = 10');
|
||||
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
|
||||
$this->assertRejected("INSERT INTO files (model_id, folder_id, name, managed_path)
|
||||
VALUES (NULL, 10, 'duplicate', 'parts/part.stl')");
|
||||
$this->pdo->exec('UPDATE files SET model_id = 20 WHERE id = 30');
|
||||
RepositoryDatabase::rollbackTo($this->path, self::OLD);
|
||||
self::assertSame($oldIndexes, $this->indexes());
|
||||
self::assertSame($before, $this->rows());
|
||||
$this->assertRejected('UPDATE files SET model_id = NULL WHERE id = 30');
|
||||
$this->assertRejected("INSERT INTO models (folder_id, name) VALUES (10, 'First')");
|
||||
RepositoryDatabase::migrateTo($this->path, self::NEW);
|
||||
self::assertSame($before, $this->rows());
|
||||
$model = $this->pdo->query("INSERT INTO models (folder_id, name) VALUES (10, 'First') RETURNING id");
|
||||
self::assertGreaterThan(21, (int) $model->fetchColumn());
|
||||
$model->closeCursor();
|
||||
$file = $this->pdo->query("INSERT INTO files (model_id, folder_id, name, managed_path)
|
||||
VALUES (NULL, 10, 'new', 'new.stl') RETURNING id");
|
||||
self::assertGreaterThan(30, (int) $file->fetchColumn());
|
||||
$file->closeCursor();
|
||||
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
|
||||
$this->pdo->exec('DELETE FROM models WHERE id = 20');
|
||||
self::assertSame(2, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
|
||||
self::assertSame([], $this->pdo->query('PRAGMA foreign_key_check')->fetchAll());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?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 PostgresCatalogTest extends TestCase
|
||||
{
|
||||
private string $database;
|
||||
private PDO $pdo;
|
||||
private PDO $other;
|
||||
private PdoCatalogRepository $catalog;
|
||||
private PdoCatalogRepository $contender;
|
||||
private \ArrayObject $locks;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
if (!RepositoryDatabase::postgresConfigured()) {
|
||||
self::markTestSkipped('Set MESHVAULT_TEST_PG_DSN to run PostgreSQL catalog contention tests.');
|
||||
}
|
||||
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-pg-catalog-');
|
||||
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||
$this->other = RepositoryDatabase::connect($this->database);
|
||||
foreach ([$this->pdo, $this->other] as $pdo) {
|
||||
$pdo->exec("SET lock_timeout = '250ms'");
|
||||
$pdo->exec("SET statement_timeout = '5s'");
|
||||
}
|
||||
$this->locks = new \ArrayObject();
|
||||
$this->other->setAttribute(PDO::ATTR_STATEMENT_CLASS, [CatalogLockStatement::class, [$this->locks]]);
|
||||
$this->catalog = new PdoCatalogRepository($this->pdo);
|
||||
$this->contender = new PdoCatalogRepository($this->other);
|
||||
self::assertNotSame(
|
||||
$this->pdo->query('SELECT pg_backend_pid()')->fetchColumn(),
|
||||
$this->other->query('SELECT pg_backend_pid()')->fetchColumn(),
|
||||
);
|
||||
self::assertSame(
|
||||
$this->pdo->query('SELECT current_schema()')->fetchColumn(),
|
||||
$this->other->query('SELECT current_schema()')->fetchColumn(),
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (['pdo', 'other'] as $property) {
|
||||
if (isset($this->$property) && $this->$property->inTransaction()) {
|
||||
$this->$property->rollBack();
|
||||
}
|
||||
}
|
||||
unset($this->catalog, $this->contender, $this->pdo, $this->other);
|
||||
if (isset($this->database)) {
|
||||
RepositoryDatabase::cleanup($this->database);
|
||||
}
|
||||
}
|
||||
|
||||
private function fixture(): array
|
||||
{
|
||||
$source = $this->catalog->createModel('/parts', 'Source');
|
||||
$target = $this->catalog->createModel('/parts', 'Target');
|
||||
$low = $this->catalog->recordFile($source, 'parts/low.stl', 'low', 3);
|
||||
$high = $this->catalog->recordFile($source, 'parts/high.stl', 'high', 4);
|
||||
return [$source, $target, $low, $high];
|
||||
}
|
||||
|
||||
private function timeout(callable $operation): void
|
||||
{
|
||||
try {
|
||||
$operation();
|
||||
self::fail('Expected a PostgreSQL lock timeout, not a successful write or no-op.');
|
||||
} catch (PDOException $error) {
|
||||
self::assertSame('55P03', $error->getCode());
|
||||
}
|
||||
}
|
||||
|
||||
private function files(): array
|
||||
{
|
||||
return $this->other->query('SELECT * FROM files ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
#[DataProvider('transactionOutcomes')]
|
||||
public function testUncommittedOwnerBlocksThenCommitRejectsStaleOrRollbackAllowsRetry(bool $commit): void
|
||||
{
|
||||
[$source, $target, $low, $high] = $this->fixture();
|
||||
$before = $this->files();
|
||||
$this->pdo->beginTransaction();
|
||||
$this->catalog->changeFileOwner([$low, $high], $source, $target);
|
||||
$this->timeout(fn () => $this->contender->changeFileOwner([$high, $low], $source, null));
|
||||
self::assertFalse($this->other->inTransaction());
|
||||
self::assertSame($before, $this->files());
|
||||
if ($commit) {
|
||||
$this->pdo->commit();
|
||||
$committed = $this->files();
|
||||
try {
|
||||
$this->contender->changeFileOwner([$high, $low], $source, null);
|
||||
self::fail('Stale expected ownership must not overwrite the winner.');
|
||||
} catch (DomainException $error) {
|
||||
self::assertSame('File ownership has changed.', $error->getMessage());
|
||||
}
|
||||
self::assertSame($committed, $this->files());
|
||||
self::assertSame([$target, $target], array_column($committed, 'model_id'));
|
||||
} else {
|
||||
$this->pdo->rollBack();
|
||||
$this->contender->changeFileOwner([$high, $low], $source, null);
|
||||
self::assertSame([null, null], array_column($this->files(), 'model_id'));
|
||||
}
|
||||
self::assertFalse($this->other->inTransaction());
|
||||
}
|
||||
|
||||
#[DataProvider('associationTargets')]
|
||||
public function testReversedLocksAndNestedFailureReleaseLowerLockWithoutPartialWrites(bool $noop): void
|
||||
{
|
||||
[$source, $target, $low, $high] = $this->fixture();
|
||||
$before = $this->files();
|
||||
$this->pdo->beginTransaction();
|
||||
$held = $this->pdo->prepare('SELECT id FROM files WHERE id = ? FOR SHARE');
|
||||
$held->execute([$high]);
|
||||
$held->closeCursor();
|
||||
$this->other->beginTransaction();
|
||||
$this->other->exec("INSERT INTO tags (name) VALUES ('outer survives')");
|
||||
$this->timeout(fn () => $this->contender->changeFileOwner(
|
||||
[$high, $low],
|
||||
$source,
|
||||
$noop ? $source : $target,
|
||||
));
|
||||
// A timeout alone cannot establish order, especially when no UPDATE would occur.
|
||||
self::assertSame([[$low], [$high]], $this->locks->getArrayCopy());
|
||||
self::assertTrue($this->other->inTransaction());
|
||||
self::assertSame($before, $this->files());
|
||||
self::assertSame('outer survives', $this->other->query('SELECT name FROM tags')->fetchColumn());
|
||||
// NOWAIT proves the lower lock was released by B's savepoint rollback, not B's transaction end.
|
||||
$released = $this->pdo->prepare('SELECT id FROM files WHERE id = ? FOR UPDATE NOWAIT');
|
||||
$released->execute([$low]);
|
||||
self::assertSame($low, (int) $released->fetchColumn());
|
||||
$released->closeCursor();
|
||||
$this->pdo->rollBack();
|
||||
$this->contender->changeFileOwner([$high, $low], $source, $noop ? $source : $target);
|
||||
self::assertTrue($this->other->inTransaction());
|
||||
self::assertSame(
|
||||
[$noop ? $source : $target, $noop ? $source : $target],
|
||||
array_column($this->files(), 'model_id'),
|
||||
);
|
||||
$this->other->rollBack();
|
||||
self::assertSame($before, $this->files());
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM tags')->fetchColumn());
|
||||
}
|
||||
|
||||
public static function associationTargets(): array
|
||||
{
|
||||
return ['reassignment' => [false], 'same-owner no-op' => [true]];
|
||||
}
|
||||
|
||||
#[DataProvider('creationMethods')]
|
||||
public function testRootSerializesCreationAndLegacyLookupAfterRelease(string $method): void
|
||||
{
|
||||
$this->catalog->createModel('/', 'Root fixture');
|
||||
$this->pdo->beginTransaction();
|
||||
$winner = $this->catalog->$method('/contested', 'Shared title');
|
||||
foreach (['createModel', 'ensureModel'] as $contenderMethod) {
|
||||
$this->timeout(fn () => $this->contender->$contenderMethod('/contested', 'Shared title'));
|
||||
// A disjoint folder/title cannot contend on the winner's new folder or model rows.
|
||||
$this->timeout(fn () => $this->contender->$contenderMethod('/unrelated', 'Other title'));
|
||||
self::assertFalse($this->other->inTransaction());
|
||||
self::assertSame(1, (int) $this->other->query('SELECT COUNT(*) FROM models')->fetchColumn());
|
||||
self::assertSame(1, (int) $this->other->query('SELECT COUNT(*) FROM folders')->fetchColumn());
|
||||
}
|
||||
$this->pdo->commit();
|
||||
self::assertSame($winner, $this->contender->ensureModel('/contested', 'Shared title'));
|
||||
$duplicate = $this->contender->createModel('/contested', 'Shared title');
|
||||
self::assertNotSame($winner, $duplicate);
|
||||
try {
|
||||
$this->contender->ensureModel('/contested', 'Shared title');
|
||||
self::fail('Legacy lookup must reject two explicit identities.');
|
||||
} catch (DomainException $error) {
|
||||
self::assertSame('Ambiguous model title in home folder.', $error->getMessage());
|
||||
}
|
||||
self::assertFalse($this->other->inTransaction());
|
||||
self::assertSame(3, (int) $this->other->query('SELECT COUNT(*) FROM models')->fetchColumn());
|
||||
}
|
||||
|
||||
public static function creationMethods(): array
|
||||
{
|
||||
return ['explicit winner' => ['createModel'], 'legacy winner' => ['ensureModel']];
|
||||
}
|
||||
|
||||
public static function transactionOutcomes(): array
|
||||
{
|
||||
return ['commit' => [true], 'rollback' => [false]];
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,45 @@ final class RepositoryDatabase
|
||||
/** @var array<string, array{schema: string, connection: PDO}> */
|
||||
private static array $schemas = [];
|
||||
|
||||
/** @var array<string, Config> */
|
||||
private static array $configs = [];
|
||||
|
||||
public static function migrateTo(string $path, int $target): void
|
||||
{
|
||||
self::runTarget($path, $target, false);
|
||||
}
|
||||
|
||||
public static function rollbackTo(string $path, int $target): void
|
||||
{
|
||||
self::runTarget($path, $target, true);
|
||||
}
|
||||
|
||||
private static function runTarget(string $path, int $target, bool $down): void
|
||||
{
|
||||
$config = self::$configs[$path] ?? throw new \LogicException('Migrate this isolated path first.');
|
||||
$manager = new Manager($config, new ArrayInput([]), new NullOutput());
|
||||
$pdo = $config->getEnvironment('test')['connection'];
|
||||
if ($pdo->inTransaction()) {
|
||||
throw new \LogicException('Migration helpers require no active caller transaction.');
|
||||
}
|
||||
try {
|
||||
if ($down) {
|
||||
$manager->rollback('test', $target);
|
||||
} else {
|
||||
$manager->migrate('test', $target);
|
||||
}
|
||||
} catch (\Throwable $error) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $error;
|
||||
} finally {
|
||||
if (isset(self::$schemas[$path])) {
|
||||
$pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function postgresConfigured(): bool
|
||||
{
|
||||
$dsn = getenv('MESHVAULT_TEST_PG_DSN');
|
||||
@@ -23,9 +62,9 @@ final class RepositoryDatabase
|
||||
}
|
||||
|
||||
/** Never a hand-maintained test schema: drift must fail the suite. */
|
||||
public static function migrate(string $path): PDO
|
||||
public static function migrate(string $path, ?int $target = null): PDO
|
||||
{
|
||||
if (isset(self::$schemas[$path])) {
|
||||
if (isset(self::$configs[$path]) || isset(self::$schemas[$path])) {
|
||||
throw new \LogicException('Clean up the previous database before migrating this path again.');
|
||||
}
|
||||
$environment = ['adapter' => 'sqlite'];
|
||||
@@ -48,7 +87,8 @@ final class RepositoryDatabase
|
||||
'test' => $environment + ['connection' => $pdo],
|
||||
],
|
||||
]);
|
||||
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
|
||||
self::$configs[$path] = $config;
|
||||
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test', $target);
|
||||
if (isset(self::$schemas[$path])) {
|
||||
// Phinx may add public to search_path; repository queries must remain isolated.
|
||||
$pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"');
|
||||
@@ -78,6 +118,7 @@ final class RepositoryDatabase
|
||||
|
||||
public static function cleanup(string $path): void
|
||||
{
|
||||
unset(self::$configs[$path]);
|
||||
if (isset(self::$schemas[$path])) {
|
||||
['schema' => $schema, 'connection' => $pdo] = self::$schemas[$path];
|
||||
if ($pdo->inTransaction()) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Unit\Catalog;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MeshVault\Catalog\ManagedPath;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ManagedPathTest extends TestCase
|
||||
{
|
||||
#[DataProvider('validPaths')]
|
||||
public function testPreservesCanonicalSpelling(string $path): void
|
||||
{
|
||||
self::assertSame($path, ManagedPath::validate($path));
|
||||
}
|
||||
|
||||
public static function validPaths(): array
|
||||
{
|
||||
return [['part.stl'], ['Folder/模型 A.STL'], ['a/.hidden'], ['a/space inside.stl']];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidPaths')]
|
||||
public function testRejectsAliases(string $path): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
ManagedPath::validate($path);
|
||||
}
|
||||
|
||||
public static function invalidPaths(): array
|
||||
{
|
||||
return array_map(static fn (string $path): array => [$path], [
|
||||
'', '/a', '//server/a', 'C:/a', 'C:a', 'a\\b', "a\0b", 'https://host/a',
|
||||
'file:a', 'a//b', 'a/', '.', '..', './a', 'a/../b', 'a/./b',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPlatformSpecificRestrictions(): void
|
||||
{
|
||||
foreach (['a/b.', 'a/b ', 'a/b:c'] as $path) {
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
try {
|
||||
ManagedPath::validate($path);
|
||||
self::fail('Expected Windows alias rejection');
|
||||
} catch (InvalidArgumentException) {
|
||||
self::assertTrue(true);
|
||||
}
|
||||
} else {
|
||||
self::assertSame($path, ManagedPath::validate($path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user