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.
117 lines
4.9 KiB
PHP
117 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MeshVault\Tests\Unit\Console;
|
|
|
|
use MeshVault\Config\AppConfig;
|
|
use MeshVault\Repository\CatalogRepositoryInterface;
|
|
use MeshVault\Repository\ImportJobRepositoryInterface;
|
|
use MeshVault\Repository\PdoCatalogRepository;
|
|
use MeshVault\Repository\PdoImportJobRepository;
|
|
use PDO;
|
|
use Phinx\Config\Config;
|
|
use Phinx\Migration\Manager;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\Console\Input\ArrayInput;
|
|
use Symfony\Component\Console\Output\NullOutput;
|
|
|
|
final class ContainerWiringTest extends TestCase
|
|
{
|
|
private PDO $pdo;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->pdo = new PDO('sqlite::memory:', null, null, [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
]);
|
|
$config = new Config([
|
|
'paths' => ['migrations' => __DIR__ . '/../../../db/migrations'],
|
|
'environments' => [
|
|
'default_migration_table' => 'phinxlog',
|
|
'default_database' => 'test',
|
|
'test' => ['adapter' => 'sqlite', 'connection' => $this->pdo],
|
|
],
|
|
]);
|
|
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
|
|
$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
|
|
// instance so the job repository's atomic() is a real transaction, not a no-op.
|
|
$catalog = new PdoCatalogRepository($this->pdo);
|
|
$jobs = new PdoImportJobRepository($this->pdo);
|
|
|
|
$reflectionCatalog = new \ReflectionObject($catalog);
|
|
$reflectionJobs = new \ReflectionObject($jobs);
|
|
|
|
$pdoCatalog = $reflectionCatalog->getProperty('pdo');
|
|
$pdoCatalog->setAccessible(true);
|
|
$pdoJobs = $reflectionJobs->getProperty('pdo');
|
|
$pdoJobs->setAccessible(true);
|
|
|
|
self::assertSame(
|
|
$pdoCatalog->getValue($catalog),
|
|
$pdoJobs->getValue($jobs),
|
|
'CatalogRepository and ImportJobRepository must share the same PDO instance for atomic() to work'
|
|
);
|
|
}
|
|
|
|
public function testAtomicCallbackUsesSharedConnection(): void
|
|
{
|
|
$catalog = new PdoCatalogRepository($this->pdo);
|
|
$jobs = new PdoImportJobRepository($this->pdo);
|
|
|
|
// Test that atomic() on jobs uses the same connection as catalog
|
|
$called = false;
|
|
$jobs->atomic(function () use ($jobs, $catalog, &$called): void {
|
|
$called = true;
|
|
// Inside the transaction, both repositories should see each other's changes
|
|
$jobId = $jobs->create('/tmp/source', [
|
|
['source_path' => '/tmp/source/file.stl', 'size' => 100, 'model_id' => null],
|
|
]);
|
|
$items = $jobs->items($jobId);
|
|
self::assertCount(1, $items);
|
|
});
|
|
|
|
self::assertTrue($called);
|
|
// After commit, the job should be visible
|
|
$allJobs = $this->pdo->query('SELECT * FROM import_jobs')->fetchAll(PDO::FETCH_ASSOC);
|
|
self::assertCount(1, $allJobs);
|
|
}
|
|
}
|