database = tempnam(sys_get_temp_dir(), 'meshvault-repository-'); $this->pdo = RepositoryDatabase::migrate($this->database); } protected function tearDown(): void { unset($this->pdo); unlink($this->database); } public function testCatalogGroupingAndFileLifecycle(): void { self::assertTrue(class_exists(PdoCatalogRepository::class), 'Catalog repository must exist'); $catalog = new PdoCatalogRepository($this->pdo); self::assertInstanceOf(CatalogRepositoryInterface::class, $catalog); $model = $catalog->ensureModel('/vehicles/aircraft', 'Plane'); self::assertSame($model, $catalog->ensureModel('/vehicles/aircraft', 'Plane')); self::assertNotSame($model, $catalog->ensureModel('/vehicles/aircraft', 'Other')); $root = $catalog->ensureModel('/', 'Root'); self::assertNull($catalog->aggregate($root)['folder']['parent_id']); self::assertSame(3, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn()); $file = $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12, 'model/stl'); self::assertSame($file, $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12)); foreach (['Missing', 'Updated', 'Duplicate', 'Present'] as $status) { $catalog->setFileStatus($file, $status); self::assertSame($status, $catalog->aggregate($model)['files'][0]['status']); } $aggregate = $catalog->aggregate($model); self::assertSame('Plane', $aggregate['model']['name']); self::assertSame('/vehicles/aircraft', $aggregate['folder']['path']); self::assertSame('model/stl', $aggregate['files'][0]['mime_type']); self::assertNull($catalog->aggregate(99999)); $this->expectException(DomainException::class); $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('b', 64), 12); } public function testRecoveryReleaseCancellationFailureAndEmptyJobs(): void { self::assertTrue(class_exists(PdoImportJobRepository::class)); $jobs = new PdoImportJobRepository($this->pdo); $manifest = [['source_path' => '/a', 'size' => 5, 'model_id' => null]]; $job = $jobs->create('/', $manifest); $jobs->claimNext(); $item = (int) $jobs->items($job)[0]['id']; $jobs->planItem($item, 'a', 'hash', 5); $jobs->release($job); self::assertSame('Pending', $jobs->get($job)['status']); $jobs->claimNext(); $jobs->resumeInterrupted(); self::assertSame('Pending', $jobs->items($job)[0]['status']); self::assertSame('a', $jobs->items($job)[0]['managed_path']); $this->pdo->exec("UPDATE import_jobs SET status = 'Scanning' WHERE id = $job"); $jobs->resumeInterrupted(); self::assertSame('Pending', $jobs->get($job)['status']); $jobs->cancel($job); $jobs->cancel($job); $jobs->resumeInterrupted(); self::assertSame('Cancelled', $jobs->get($job)['status']); self::assertSame('Skipped', $jobs->items($job)[0]['status']); $failed = $jobs->create('/', $manifest); $jobs->fail($failed, 'disk full'); $jobs->fail($failed, 'disk full'); self::assertSame('Failed', $jobs->get($failed)['status']); self::assertSame('disk full', $jobs->items($failed)[0]['failure_reason']); $empty = $jobs->create('/', []); $jobs->completeEmpty($empty); $jobs->completeEmpty($empty); self::assertSame('Completed', $jobs->get($empty)['status']); $scan = $jobs->create('/', [ ['source_path' => '/bad', 'size' => 0, 'model_id' => null, 'status' => 'Failed', 'failure_reason' => 'scan failed'], ]); self::assertSame('Failed', $jobs->get($scan)['status']); self::assertSame(1, (int) $jobs->get($scan)['processed_files']); self::assertNull($jobs->claimNext()); } public function testImmutablePlansAndSuccessfulCompletion(): void { self::assertTrue(class_exists(PdoImportJobRepository::class)); $jobs = new PdoImportJobRepository($this->pdo); $job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]); $jobs->claimNext(); $item = (int) $jobs->items($job)[0]['id']; $jobs->planItem($item, 'a', 'hash', 5); foreach ([['b', 'hash', 5], ['a', 'other', 5], ['a', 'hash', 6]] as $plan) { try { $jobs->planItem($item, ...$plan); self::fail('Plan mutation accepted'); } catch (DomainException) { self::assertSame('a', $jobs->items($job)[0]['managed_path']); } } $jobs->finishItem($item, 'Completed'); self::assertSame('Completed', $jobs->get($job)['status']); $this->expectException(DomainException::class); $jobs->finishItem($item, 'Failed', 'late failure'); } public function testAtomicRollbackAndIndependentClaims(): void { self::assertTrue(class_exists(PdoImportJobRepository::class)); $jobs = new PdoImportJobRepository($this->pdo); $catalog = new PdoCatalogRepository($this->pdo); $job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]); $second = $jobs->create('/', []); $other = new PdoImportJobRepository(new PDO('sqlite:' . $this->database)); self::assertSame($job, (int) $jobs->claimNext()['id']); self::assertSame($second, (int) $other->claimNext()['id']); self::assertNull($other->claimNext()); try { $jobs->atomic(function () use ($jobs, $catalog, $job): void { $model = $catalog->ensureModel('/rollback', 'Rollback'); $catalog->recordFile($model, 'rollback/a', 'hash', 5); $jobs->finishItem((int) $jobs->items($job)[0]['id'], 'Completed'); throw new RuntimeException('rollback'); }); self::fail('Expected rollback'); } catch (RuntimeException $error) { self::assertSame('rollback', $error->getMessage()); } self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM models')->fetchColumn()); self::assertSame('Pending', $jobs->items($job)[0]['status']); self::assertSame(42, $jobs->atomic(static fn (): int => 42)); $before = (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn(); try { $jobs->create('/', [ ['source_path' => '/valid', 'size' => 1, 'model_id' => null], ['source_path' => '/invalid', 'size' => 1, 'model_id' => 99999], ]); self::fail('Expected foreign key rejection'); } catch (\PDOException) { self::assertSame($before, (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn()); } } public function testManifestClaimPlanProgressAndIdempotency(): void { self::assertTrue(class_exists(PdoImportJobRepository::class), 'Job repository must exist'); $jobs = new PdoImportJobRepository($this->pdo); self::assertInstanceOf(ImportJobRepositoryInterface::class, $jobs); $job = $jobs->create('/source', [ ['source_path' => '/source/a', 'size' => 10, 'model_id' => null], ['source_path' => '/source/b', 'size' => 20, 'model_id' => null], ['source_path' => '/source/c', 'size' => 30, 'model_id' => null], ]); self::assertSame('Pending', $jobs->get($job)['status']); self::assertSame(60, (int) $jobs->get($job)['total_bytes']); self::assertSame($job, (int) $jobs->claimNext()['id']); self::assertNotNull($jobs->get($job)['started_at']); self::assertNull($jobs->claimNext()); $items = $jobs->items($job); $jobs->planItem((int) $items[0]['id'], 'a.stl', str_repeat('a', 64), 10); $jobs->planItem((int) $items[0]['id'], 'a.stl', str_repeat('a', 64), 10); self::assertSame('Copying', $jobs->items($job)[0]['status']); self::assertSame('a.stl', $jobs->items($job)[0]['managed_path']); $jobs->finishItem((int) $items[0]['id'], 'Completed'); $jobs->finishItem((int) $items[0]['id'], 'Completed'); self::assertSame(1, (int) $jobs->get($job)['processed_files']); self::assertSame(10, (int) $jobs->get($job)['copied_bytes']); $jobs->finishItem((int) $items[1]['id'], 'Skipped'); $jobs->finishItem((int) $items[2]['id'], 'Failed', 'unreadable'); self::assertSame('Failed', $jobs->get($job)['status']); self::assertSame(3, (int) $jobs->get($job)['processed_files']); self::assertSame('unreadable', $jobs->items($job)[2]['failure_reason']); self::assertNotNull($jobs->get($job)['completed_at']); self::assertNull($jobs->get(99999)); self::assertSame([], $jobs->items(99999)); } }