From b8451112d34f81b884b7d7b0e4e6c332ba2c7ebb Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 25 Jul 2026 23:47:09 -0500 Subject: [PATCH] feat: add GetBundledScenario handler with fixture validation --- src/Http/Handlers/GetBundledScenario.php | 71 ++++++++ tests/Integration/GetBundledScenarioTest.php | 162 +++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 src/Http/Handlers/GetBundledScenario.php create mode 100644 tests/Integration/GetBundledScenarioTest.php diff --git a/src/Http/Handlers/GetBundledScenario.php b/src/Http/Handlers/GetBundledScenario.php new file mode 100644 index 0000000..d94e8f0 --- /dev/null +++ b/src/Http/Handlers/GetBundledScenario.php @@ -0,0 +1,71 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $id = (string) ($params['id'] ?? ''); + + if (preg_match(self::ID_PATTERN, $id) !== 1) { + return Response::json(404, ['error' => 'not found']); + } + + $manifestPath = $this->scenariosDir . '/manifest.json'; + $manifest = []; + if (is_file($manifestPath)) { + $decoded = json_decode((string) file_get_contents($manifestPath), true); + if (is_array($decoded)) { + $manifest = $decoded; + } + } + + if (!isset($manifest[$id])) { + return Response::json(404, ['error' => 'not found']); + } + + $fixturePath = $this->scenariosDir . '/' . $id . '.json'; + $real = realpath($fixturePath); + $realDir = realpath($this->scenariosDir); + $insideBase = $real !== false + && $realDir !== false + && str_starts_with($real, $realDir . DIRECTORY_SEPARATOR); + if ($insideBase === false || !is_file($real)) { + return Response::json(404, ['error' => 'not found']); + } + + $data = json_decode((string) file_get_contents($real), true, flags: JSON_THROW_ON_ERROR); + if (!is_array($data)) { + return Response::json(500, ['error' => 'fixture invalid', 'details' => ['Fixture JSON is not an object.']]); + } + + try { + $scenario = ScenarioSerializer::scenarioFromArray($data); + } catch (InvalidArgumentException $exception) { + return Response::json(500, ['error' => 'fixture invalid', 'details' => [$exception->getMessage()]]); + } + + $errors = ScenarioValidator::validate($scenario); + if ($errors !== []) { + return Response::json(500, ['error' => 'fixture invalid', 'details' => $errors]); + } + + return Response::json(200, ScenarioSerializer::scenarioToArray($scenario)); + } +} diff --git a/tests/Integration/GetBundledScenarioTest.php b/tests/Integration/GetBundledScenarioTest.php new file mode 100644 index 0000000..09e597f --- /dev/null +++ b/tests/Integration/GetBundledScenarioTest.php @@ -0,0 +1,162 @@ +tmpDir = sys_get_temp_dir() . '/bf-bundled-one-' . bin2hex(random_bytes(4)); + mkdir($this->tmpDir, 0700, true); + file_put_contents( + $this->tmpDir . '/manifest.json', + json_encode(['valid' => ['name' => 'Valid', 'summary' => 'OK']], JSON_THROW_ON_ERROR), + ); + $this->writeValidFixture(); + } + + protected function tearDown(): void + { + if (is_dir($this->tmpDir)) { + foreach (glob($this->tmpDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->tmpDir); + } + } + + public function testReturnsTheScenarioJsonForAKnownId(): void + { + $handler = new GetBundledScenario($this->tmpDir); + $response = $handler->handle( + new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/valid', null, ''), + ['id' => 'valid'], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame('valid', $body['id'] ?? null); + self::assertSame('Valid', $body['name'] ?? null); + self::assertSame(8, $body['battlefield']['width'] ?? null); + } + + public function testReturns404ForAnUnknownId(): void + { + $handler = new GetBundledScenario($this->tmpDir); + $response = $handler->handle( + new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/unknown', null, ''), + ['id' => 'unknown'], + ); + + self::assertSame(404, $response->status); + } + + public function testReturns404ForAnIdThatFailsTheRegex(): void + { + $handler = new GetBundledScenario($this->tmpDir); + $response = $handler->handle( + new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/..', null, ''), + ['id' => '..'], + ); + + self::assertSame(404, $response->status); + } + + public function testReturns500WhenTheFixtureFailsValidation(): void + { + $bad = [ + 'id' => 'broken', + 'name' => 'Broken', + 'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []], + 'units' => [], + 'deploymentZones' => [], + 'objectives' => [], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + file_put_contents($this->tmpDir . '/broken.json', json_encode($bad, JSON_THROW_ON_ERROR)); + file_put_contents( + $this->tmpDir . '/manifest.json', + json_encode([ + 'valid' => ['name' => 'Valid', 'summary' => 'OK'], + 'broken' => ['name' => 'Broken', 'summary' => 'Bad'], + ], JSON_THROW_ON_ERROR), + ); + + $handler = new GetBundledScenario($this->tmpDir); + $response = $handler->handle( + new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/broken', null, ''), + ['id' => 'broken'], + ); + + self::assertSame(500, $response->status); + $body = json_decode($response->body, true); + self::assertSame('fixture invalid', $body['error'] ?? null); + self::assertIsArray($body['details'] ?? null); + self::assertNotEmpty($body['details']); + } + + private function writeValidFixture(): void + { + $fixture = [ + 'id' => 'valid', + 'name' => 'Valid', + 'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []], + 'units' => [ + $this->unit('a1', 'alpha', 0, 0), + $this->unit('a2', 'alpha', 1, 0), + $this->unit('a3', 'alpha', 2, 0), + $this->unit('b1', 'bravo', 7, 7), + $this->unit('b2', 'bravo', 6, 7), + $this->unit('b3', 'bravo', 5, 7), + ], + 'deploymentZones' => [ + 'alpha' => $this->deploymentZone('alpha', [[0, 0], [1, 0], [2, 0]]), + 'bravo' => $this->deploymentZone('bravo', [[7, 7], [6, 7], [5, 7]]), + ], + 'objectives' => [], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + file_put_contents($this->tmpDir . '/valid.json', json_encode($fixture, JSON_THROW_ON_ERROR)); + } + + /** @return array */ + private function unit(string $id, string $teamId, int $x, int $y): array + { + return [ + 'id' => $id, + 'teamId' => $teamId, + 'position' => ['x' => $x, 'y' => $y], + 'maxHealth' => 10, + 'health' => 10, + 'attack' => 3, + 'defense' => 3, + 'speed' => 2, + 'archetype' => 'defender', + 'abilities' => [], + ]; + } + + /** + * @param list $coords + * @return array + */ + private function deploymentZone(string $teamId, array $coords): array + { + $positions = []; + foreach ($coords as $coord) { + $positions[] = ['x' => $coord[0], 'y' => $coord[1]]; + } + + return ['teamId' => $teamId, 'positions' => $positions]; + } +}