386 lines
17 KiB
PHP
386 lines
17 KiB
PHP
<?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();
|
|
}
|
|
}
|