execute(['help']); self::assertSame(0, $code); self::assertSame('', $err); foreach (['import', 'worker', 'worker-once', '--ext'] as $fragment) { self::assertStringContainsString($fragment, $out); } } public function testMissingCommandIsAUsageError(): void { [$code, $out, $err] = $this->execute([]); self::assertSame(1, $code); self::assertSame('', $out); self::assertStringContainsString('Usage: meshvault', $err); } public function testUnknownCommandIsAUsageError(): void { [$code, , $err] = $this->execute(['nope']); self::assertSame(1, $code); self::assertStringContainsString('Unknown command', $err); } public function testImportWithoutSourceIsAUsageError(): void { [$code, , $err] = $this->execute(['import']); self::assertSame(1, $code); self::assertStringContainsString('import ', $err); } public function testImportRejectsExtraOperandsAndUnknownOptions(): void { [$code, , $err] = $this->execute(['import', 'a', 'b']); self::assertSame(1, $code); self::assertStringContainsString('one source path', $err); [$code, , $err] = $this->execute(['import', 'a', '--deep']); self::assertSame(1, $code); self::assertStringContainsString('Unknown option', $err); self::assertStringContainsString('--deep', $err); } public function testImportRejectsEmptyExtensionList(): void { foreach (['--ext=', '--ext= , '] as $option) { [$code, , $err] = $this->execute(['import', 'a', $option]); self::assertSame(1, $code, $option); self::assertStringContainsString('--ext', $err); } } public function testImportQueuesAJobWithDefaultExtensions(): void { $services = new FakeConsoleServices(importResult: 42); [$code, $out] = $this->execute(['import', '/srv/incoming'], $services); self::assertSame(0, $code); self::assertSame( [['/srv/incoming', ['stl', 'jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'md']]], $services->imports ); self::assertStringContainsString('42', $out); } public function testImportNormalizesTheExtensionOption(): void { $services = new FakeConsoleServices(importResult: 7); [$code] = $this->execute(['import', '/srv/in', '--ext= STL , .3mf ,stl'], $services); self::assertSame(0, $code); self::assertSame([['/srv/in', ['stl', '3mf']]], $services->imports); } public function testImportFailureReportsTheReasonCodeAndExitsTwo(): void { $services = new FakeConsoleServices(importError: new ImportException('SCAN_EMPTY', 'Nothing found.')); [$code, $out, $err] = $this->execute(['import', '/srv/in'], $services); self::assertSame(2, $code); self::assertSame('', $out); self::assertStringContainsString('SCAN_EMPTY', $err); self::assertStringContainsString('Nothing found.', $err); } public function testUnexpectedFailureIsReportedWithoutLeakingDetails(): void { $services = new FakeConsoleServices( importError: new RuntimeException('SQLSTATE[08006] password=hunter2') ); [$code, , $err] = $this->execute(['import', '/srv/in'], $services); self::assertSame(2, $code); self::assertStringNotContainsString('hunter2', $err); self::assertStringNotContainsString('#0 ', $err); self::assertStringContainsString('RuntimeException', $err); } public function testWorkerOnceReportsTheProcessedJob(): void { $services = new FakeConsoleServices(jobs: [11]); [$code, $out] = $this->execute(['worker-once'], $services); self::assertSame(0, $code); self::assertSame(1, $services->runs); self::assertStringContainsString('11', $out); } public function testWorkerOnceReportsAnIdleQueue(): void { $services = new FakeConsoleServices(jobs: [null]); [$code, $out] = $this->execute(['worker-once'], $services); self::assertSame(0, $code); self::assertStringContainsString('No pending import jobs', $out); } public function testWorkerOnceRejectsOperands(): void { [$code, , $err] = $this->execute(['worker-once', 'x']); self::assertSame(1, $code); self::assertStringContainsString('takes no arguments', $err); } public function testWorkerLoopsUntilTheIterationBoundAndSleepsWhenIdle(): void { $services = new FakeConsoleServices(jobs: [5, null, null]); $slept = []; [$code, $out] = $this->execute( ['worker', '--max-iterations=3', '--sleep=2'], $services, static function (int $seconds) use (&$slept): void { $slept[] = $seconds; } ); self::assertSame(0, $code); self::assertSame(3, $services->runs); // Only idle passes sleep; a processed job is followed immediately by the next claim. self::assertSame([2, 2], $slept); self::assertStringContainsString('stopped', $out); } public function testWorkerStopsWhenAStopIsRequested(): void { $services = new FakeConsoleServices(jobs: [1, 2, 3]); $calls = 0; [$code, $out] = $this->execute( ['worker', '--max-iterations=99'], $services, null, static function () use (&$calls): bool { $calls++; return $calls > 2; } ); self::assertSame(0, $code); self::assertSame(2, $services->runs); self::assertStringContainsString('stopped', $out); } public function testWorkerRejectsInvalidOptions(): void { foreach (['--max-iterations=0', '--max-iterations=x', '--sleep=-1', '--bogus', 'operand'] as $argument) { [$code, , $err] = $this->execute(['worker', $argument]); self::assertSame(1, $code, $argument); self::assertNotSame('', $err); } } public function testWorkerFailureExitsTwo(): void { $services = new FakeConsoleServices( workerError: new ImportException('LOCK_UNAVAILABLE', 'Another worker holds the library lock.') ); [$code, , $err] = $this->execute(['worker', '--max-iterations=1'], $services); self::assertSame(2, $code); self::assertStringContainsString('LOCK_UNAVAILABLE', $err); } /** @return array{0: int, 1: string, 2: string} */ private function execute( array $argv, ?ConsoleServices $services = null, ?callable $sleep = null, ?callable $stopRequested = null ): array { $out = fopen('php://memory', 'w+'); $err = fopen('php://memory', 'w+'); $console = new Console( $services ?? new FakeConsoleServices(), $out, $err, $sleep ?? static function (int $seconds): void { }, $stopRequested ?? static fn (): bool => false ); $code = $console->run($argv); rewind($out); rewind($err); return [$code, (string) stream_get_contents($out), (string) stream_get_contents($err)]; } }