sibling explicit-grouping implementation: commit all work, removal of stray scratch files; update docs and status; keep P1 fix and other senior details
This commit is contained in:
1 parent
9dd8c07e66
commit
99f19e1052
16 files changed
+1244
-44
No files matched your search
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Integration;
|
||||
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use MeshVault\Indexer\ExplicitImportGroupingAdapter;
|
||||
use MeshVault\Indexer\ScannedFile;
|
||||
use MeshVault\Repository\PdoCatalogRepository;
|
||||
use MeshVault\Repository\PdoImportJobRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ExplicitGroupingTest extends TestCase
|
||||
{
|
||||
private string $database;
|
||||
private \PDO $pdo;
|
||||
private PdoCatalogRepository $catalog;
|
||||
private PdoImportJobRepository $jobs;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->database = tempnam(sys_get_temp_dir(), 'grouping-');
|
||||
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||
$this->catalog = new PdoCatalogRepository($this->pdo);
|
||||
$this->jobs = new PdoImportJobRepository($this->pdo);
|
||||
self::assertTrue(class_exists(ExplicitImportGroupingAdapter::class), 'Explicit adapter must exist');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RepositoryDatabase::cleanup($this->database);
|
||||
}
|
||||
|
||||
public function testServicePersistsPerFileAndWildcardDecisionsAtomically(): void
|
||||
{
|
||||
$source = RepositoryDatabase::directory('explicit-source');
|
||||
$library = RepositoryDatabase::directory('explicit-library');
|
||||
try {
|
||||
file_put_contents($source . '/a.stl', 'solid a');
|
||||
file_put_contents($source . '/b.stl', 'solid b');
|
||||
$adapter = new ExplicitImportGroupingAdapter($this->catalog, $this->jobs);
|
||||
$service = new \MeshVault\Indexer\ImportService(
|
||||
new \MeshVault\Indexer\FileEnumerator(
|
||||
new \MeshVault\Indexer\Filesystem\LocalFilesystem(),
|
||||
new \MeshVault\Indexer\Filesystem\PathSafety($source)
|
||||
),
|
||||
$this->jobs,
|
||||
$adapter,
|
||||
new \MeshVault\Indexer\SpaceChecker(),
|
||||
$library
|
||||
);
|
||||
$context = ['*' => ['confirmed' => true, 'folder_path' => '/parts', 'model_name' => 'Part']];
|
||||
$job = $service->import($source, ['stl'], $context);
|
||||
$map = $adapter->recoverDecisions($job);
|
||||
self::assertCount(2, $map);
|
||||
self::assertSame($map, $adapter->recoverDecisions($service->import($source, ['stl'], $context)));
|
||||
$before = (int) $this->pdo->query('SELECT count(*) FROM import_jobs')->fetchColumn();
|
||||
try {
|
||||
$service->import($source, ['stl'], ['a.stl' => $context['*']]);
|
||||
self::fail('Incomplete grouping accepted');
|
||||
} catch (\MeshVault\Indexer\ImportException) {
|
||||
self::assertSame($before, (int) $this->pdo->query('SELECT count(*) FROM import_jobs')->fetchColumn());
|
||||
}
|
||||
} finally {
|
||||
RepositoryDatabase::remove($source);
|
||||
RepositoryDatabase::remove($library);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolve(array $context): int
|
||||
{
|
||||
return (new ExplicitImportGroupingAdapter($this->catalog, $this->jobs))->modelFor(
|
||||
'/source',
|
||||
new ScannedFile('/source/a.stl', 'a.stl', 1, 'model/stl'),
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
public function testConfirmationAndCompleteContextAreRequired(): void
|
||||
{
|
||||
foreach (
|
||||
[[], ['confirmed' => true], ['confirmed' => 1, 'model_id' => 1],
|
||||
['confirmed' => true, 'folder_path' => '/x'], ['confirmed' => true, 'model_id' => '1']] as $context
|
||||
) {
|
||||
try {
|
||||
$this->resolve($context);
|
||||
self::fail('Malformed or unconfirmed context accepted');
|
||||
} catch (InvalidArgumentException) {
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT count(*) FROM models')->fetchColumn());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testStableIdentityNewIdentityAndAmbiguity(): void
|
||||
{
|
||||
$context = ['confirmed' => true, 'folder_path' => '/parts', 'model_name' => 'Part'];
|
||||
$id = $this->resolve($context);
|
||||
self::assertSame($id, $this->resolve($context));
|
||||
self::assertSame($id, $this->resolve(['model_id' => $id] + $context));
|
||||
self::assertNotSame($id, $this->resolve(['create_new' => true] + $context));
|
||||
$this->expectException(DomainException::class);
|
||||
$this->resolve($context);
|
||||
}
|
||||
|
||||
public function testIdentityMismatchAndMissingModelAreRejected(): void
|
||||
{
|
||||
$id = $this->catalog->createModel('/parts', 'Part');
|
||||
foreach (
|
||||
[['model_id' => $id, 'model_name' => 'Other'],
|
||||
['model_id' => $id, 'folder_path' => '/other'], ['model_id' => 999999]] as $context
|
||||
) {
|
||||
try {
|
||||
$this->resolve(['confirmed' => true] + $context);
|
||||
self::fail('Conflicting identity accepted');
|
||||
} catch (DomainException) {
|
||||
self::assertTrue(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testDurableImmutableIdempotentDecisionsAndCallerRollback(): void
|
||||
{
|
||||
$id = $this->resolve(['confirmed' => true, 'folder_path' => '/parts', 'model_name' => 'Part']);
|
||||
$job = $this->jobs->create('/source', [['source_path' => '/source/a.stl', 'size' => 1, 'model_id' => $id]]);
|
||||
$adapter = new ExplicitImportGroupingAdapter($this->catalog, $this->jobs);
|
||||
$decision = ['source_path' => '/source/a.stl', 'model_id' => $id, 'context' => ['confirmed' => true]];
|
||||
$adapter->persistDecisions($job, [$decision]);
|
||||
$adapter->persistDecisions($job, [$decision]);
|
||||
$fresh = new ExplicitImportGroupingAdapter(
|
||||
$this->catalog,
|
||||
new PdoImportJobRepository(RepositoryDatabase::connect($this->database))
|
||||
);
|
||||
self::assertSame(['/source/a.stl' => $id], $fresh->recoverDecisions($job));
|
||||
try {
|
||||
$changed = array_replace($decision, ['context' => ['confirmed' => true, 'extra' => 1]]);
|
||||
$adapter->persistDecisions($job, [$changed]);
|
||||
self::fail('Decision changed');
|
||||
} catch (DomainException) {
|
||||
self::assertSame(['/source/a.stl' => $id], $adapter->recoverDecisions($job));
|
||||
}
|
||||
$this->pdo->beginTransaction();
|
||||
$other = $this->jobs->create('/other', []);
|
||||
$adapter->persistDecisions($other, [$decision]);
|
||||
self::assertTrue($this->pdo->inTransaction());
|
||||
$this->pdo->rollBack();
|
||||
self::assertSame([], $adapter->recoverDecisions($other));
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ final class ImportPipelineTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
private function worker(?callable $shouldStop = null): ImportWorker
|
||||
private function worker(?callable $shouldStop = null, array $operations = []): ImportWorker
|
||||
{
|
||||
$safety = new PathSafety($this->library);
|
||||
$sourceSafety = new PathSafety($this->source);
|
||||
@@ -71,7 +71,7 @@ final class ImportPipelineTest extends TestCase
|
||||
$this->jobs,
|
||||
new \MeshVault\Catalog\CatalogService($this->catalog),
|
||||
new StreamingHasher($sourceSafety),
|
||||
new VerifiedCopier($safety),
|
||||
new VerifiedCopier($safety, $operations),
|
||||
new StlValidator($sourceSafety),
|
||||
$safety,
|
||||
new SpaceChecker(),
|
||||
@@ -121,10 +121,7 @@ final class ImportPipelineTest extends TestCase
|
||||
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')
|
||||
);
|
||||
self::assertFileDoesNotExist($this->library . '/planes/wing.stl');
|
||||
}
|
||||
|
||||
public function testCompletionRegistersBeforeUpdatingItemPath(): void
|
||||
@@ -159,10 +156,97 @@ final class ImportPipelineTest extends TestCase
|
||||
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'));
|
||||
self::assertFileDoesNotExist($this->library . '/unfiled/loose.stl');
|
||||
}
|
||||
|
||||
public function testCompletionSqlFailureRollsBackCatalogPathAndProgressInsideCallerTransaction(): void
|
||||
public static function invalidExplicitOwners(): array
|
||||
{
|
||||
return [
|
||||
'deleted before execution' => [true, false, false],
|
||||
'deleted before recovery' => [true, true, false],
|
||||
'different manifest owner' => [false, false, true],
|
||||
'missing historical owner with replacement' => [true, false, true],
|
||||
];
|
||||
}
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('invalidExplicitOwners')]
|
||||
public function testExplicitOwnershipFailsClosed(bool $delete, bool $recover, bool $replace): void
|
||||
{
|
||||
$source = $this->writeStl('part.stl');
|
||||
$adapter = new \MeshVault\Indexer\ExplicitImportGroupingAdapter($this->catalog, $this->jobs);
|
||||
$service = new ImportService(
|
||||
new FileEnumerator(new LocalFilesystem(), new PathSafety($this->source)),
|
||||
$this->jobs,
|
||||
$adapter,
|
||||
new SpaceChecker(),
|
||||
$this->library,
|
||||
);
|
||||
$model = $this->catalog->createModel('/confirmed', 'Part');
|
||||
$job = $service->import($this->source, ['stl'], [
|
||||
'*' => ['confirmed' => true, 'model_id' => $model],
|
||||
]);
|
||||
$item = $this->jobs->items($job)[0];
|
||||
$destination = $this->library . '/confirmed/part.stl';
|
||||
if ($recover) {
|
||||
$this->jobs->claimNext();
|
||||
$this->jobs->planItem(
|
||||
(int) $item['id'],
|
||||
'confirmed/part.stl',
|
||||
hash_file('sha256', $source),
|
||||
filesize($source)
|
||||
);
|
||||
mkdir(dirname($destination));
|
||||
copy($source, $destination);
|
||||
unlink($source); // Recovery must validate ownership even without the source.
|
||||
}
|
||||
if ($delete) {
|
||||
$this->pdo->exec('DELETE FROM models WHERE id = ' . $model);
|
||||
self::assertNull($this->jobs->items($job)[0]['model_id']);
|
||||
}
|
||||
if ($replace) {
|
||||
$other = $this->catalog->createModel('/replacement', 'Other');
|
||||
$this->pdo->exec('UPDATE import_items SET model_id = ' . $other . ' WHERE id = ' . (int) $item['id']);
|
||||
}
|
||||
self::assertSame([$source => $model], $adapter->recoverDecisions($job));
|
||||
$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('Failed', $this->jobs->get($job)['status']);
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||
self::assertSame([$source => $model], $adapter->recoverDecisions($job));
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
self::assertFileDoesNotExist($this->library . '/unfiled/part.stl');
|
||||
self::assertFileDoesNotExist($this->library . '/replacement/part.stl');
|
||||
if ($recover) {
|
||||
self::assertSame($item['hash'], hash_file('sha256', $destination));
|
||||
self::assertSame('confirmed/part.stl', $item['managed_path']);
|
||||
} else {
|
||||
self::assertNull($item['managed_path']);
|
||||
self::assertFileDoesNotExist($destination);
|
||||
}
|
||||
}
|
||||
|
||||
public function testCallerTransactionIsRejectedBeforePublication(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$this->worker()->runOnce();
|
||||
self::fail('Uncommitted publication intent accepted');
|
||||
} catch (\LogicException $error) {
|
||||
self::assertStringContainsString('transaction', $error->getMessage());
|
||||
} finally {
|
||||
self::assertTrue($this->pdo->inTransaction());
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
self::assertSame('Pending', $this->jobs->items($job)[0]['status']);
|
||||
self::assertFileDoesNotExist($this->library . '/planes/wing.stl');
|
||||
}
|
||||
|
||||
public function testCompletionSqlFailureCompensatesOwnedSuffix(): void
|
||||
{
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||
self::markTestSkipped('SQLite failure-injection trigger.');
|
||||
@@ -176,27 +260,17 @@ final class ImportPipelineTest extends TestCase
|
||||
// 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('planes/wing_001.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']);
|
||||
self::assertArrayNotHasKey('planes/wing_001.stl', $published);
|
||||
}
|
||||
|
||||
public function testImportCreatesJobWithLegacyGuessedSubfolderGrouping(): void
|
||||
@@ -300,6 +374,60 @@ final class ImportPipelineTest extends TestCase
|
||||
self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']);
|
||||
}
|
||||
|
||||
public function testSuffixIntentIsCommittedBeforeLinkAndRecoveryNeedsNoSource(): void
|
||||
{
|
||||
$source = $this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
mkdir($this->library . '/planes');
|
||||
file_put_contents($this->library . '/planes/wing.stl', 'occupied');
|
||||
$observed = null;
|
||||
$this->worker(null, ['link' => function (string $temp, string $dest) use ($job, &$observed): bool {
|
||||
$reader = RepositoryDatabase::connect($this->database);
|
||||
$observed = $reader->query('SELECT managed_path FROM import_items WHERE job_id = ' . $job)->fetchColumn();
|
||||
return link($temp, $dest);
|
||||
}])->runOnce();
|
||||
self::assertSame('planes/wing_001.stl', $observed);
|
||||
$this->pdo->exec("UPDATE import_items SET status = 'Copying', completed_at = NULL");
|
||||
$this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0");
|
||||
$this->pdo->exec('DELETE FROM files');
|
||||
unlink($source);
|
||||
$this->worker()->runOnce();
|
||||
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
|
||||
self::assertFileDoesNotExist($this->library . '/planes/wing_002.stl');
|
||||
}
|
||||
|
||||
public function testPublicationRaceFailsWithoutSilentSuffix(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->worker(null, ['link' => static function (string $temp, string $dest): bool {
|
||||
file_put_contents($dest, 'race winner');
|
||||
return false;
|
||||
}])->runOnce();
|
||||
self::assertSame('Failed', $this->jobs->items($job)[0]['status']);
|
||||
self::assertSame('race winner', file_get_contents($this->library . '/planes/wing.stl'));
|
||||
self::assertFileDoesNotExist($this->library . '/planes/wing_001.stl');
|
||||
}
|
||||
|
||||
public function testAdoptedBytesSurviveCompletionFailure(): 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);
|
||||
$item = $this->jobs->items($job)[0];
|
||||
$this->jobs->planItem((int) $item['id'], 'planes/wing.stl', hash_file('sha256', $source), filesize($source));
|
||||
mkdir($this->library . '/planes');
|
||||
copy($source, $this->library . '/planes/wing.stl');
|
||||
$this->pdo->exec("CREATE TRIGGER reject_adoption BEFORE UPDATE ON import_jobs"
|
||||
. " WHEN NEW.status = 'Completed' BEGIN SELECT RAISE(ABORT, 'completion failure'); END");
|
||||
$this->worker()->runOnce();
|
||||
self::assertSame('Failed', $this->jobs->items($job)[0]['status']);
|
||||
self::assertSame(file_get_contents($source), file_get_contents($this->library . '/planes/wing.stl'));
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||
}
|
||||
|
||||
public function testGracefulStopLeavesJobResumable(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
|
||||
Reference in new issue
Block a user