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,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"]];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user