From 7cb670b5123866015dcbf7bc98394522154a312b Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 22:05:47 -0500 Subject: [PATCH] feat: add battlefield editor GET and POST handlers --- src/Http/Handlers/GetBattlefieldEditor.php | 27 +++++ src/Http/Handlers/PostBattlefieldEditor.php | 43 ++++++++ .../Integration/GetBattlefieldEditorTest.php | 25 +++++ .../Integration/PostBattlefieldEditorTest.php | 101 ++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 src/Http/Handlers/GetBattlefieldEditor.php create mode 100644 src/Http/Handlers/PostBattlefieldEditor.php create mode 100644 tests/Integration/GetBattlefieldEditorTest.php create mode 100644 tests/Integration/PostBattlefieldEditorTest.php diff --git a/src/Http/Handlers/GetBattlefieldEditor.php b/src/Http/Handlers/GetBattlefieldEditor.php new file mode 100644 index 0000000..1a79d03 --- /dev/null +++ b/src/Http/Handlers/GetBattlefieldEditor.php @@ -0,0 +1,27 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $csrf = $request->cookies['__csrf'] ?? ''; + $scenarioId = $params['id'] ?? 'new'; + $width = 8; + $height = 8; + + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/battlefield-editor.php'; + $body = (string) ob_get_clean(); + + return Response::html(200, $body); + } +} diff --git a/src/Http/Handlers/PostBattlefieldEditor.php b/src/Http/Handlers/PostBattlefieldEditor.php new file mode 100644 index 0000000..c95aa14 --- /dev/null +++ b/src/Http/Handlers/PostBattlefieldEditor.php @@ -0,0 +1,43 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? ''); + $expected = (string) ($request->cookies['__csrf'] ?? ''); + $secret = (string) ($request->server['__csrf_secret'] ?? ''); + + if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { + return Response::json(403, ['error' => 'csrf']); + } + + try { + $payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + return Response::json(400, ['ok' => false, 'errors' => ['Malformed JSON.']]); + } + + try { + $draft = ScenarioDraft::fromPost($payload); + $scenario = $draft->toScenario(); + ScenarioValidator::validate($scenario); + } catch (\InvalidArgumentException $exception) { + return Response::json(400, ['ok' => false, 'errors' => [$exception->getMessage()]]); + } + + return Response::json(200, ['ok' => true, 'scenario' => ScenarioSerializer::scenarioToArray($scenario)]); + } +} diff --git a/tests/Integration/GetBattlefieldEditorTest.php b/tests/Integration/GetBattlefieldEditorTest.php new file mode 100644 index 0000000..3b2867d --- /dev/null +++ b/tests/Integration/GetBattlefieldEditorTest.php @@ -0,0 +1,25 @@ +handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? ''); + self::assertStringContainsString('class="bf-grid"', $response->body); + self::assertStringContainsString('name="_csrf"', $response->body); + } +} diff --git a/tests/Integration/PostBattlefieldEditorTest.php b/tests/Integration/PostBattlefieldEditorTest.php new file mode 100644 index 0000000..6228031 --- /dev/null +++ b/tests/Integration/PostBattlefieldEditorTest.php @@ -0,0 +1,101 @@ +buildRequest(rawBody: '{}', csrfHeader: ''); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(403, $response->status); + self::assertStringContainsString('csrf', $response->body); + } + + public function testItAcceptsAValidBattlefieldJsonAndReturnsOk(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostBattlefieldEditor(); + $request = $this->buildRequest( + rawBody: json_encode($this->validBattlefield(), JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame(true, $body['ok'] ?? null); + self::assertSame('demo', $body['scenario']['id'] ?? null); + } + + public function testItReturns400OnAnInvalidShape(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostBattlefieldEditor(); + $request = $this->buildRequest( + rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(400, $response->status); + $body = json_decode($response->body, true); + self::assertSame(false, $body['ok'] ?? null); + self::assertNotEmpty($body['errors'] ?? []); + } + + /** + * @param array $cookies + * @param array $server + */ + private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = [], array $server = []): Request + { + $server['HTTP_X_CSRF_TOKEN'] = $csrfHeader; + $server['CONTENT_TYPE'] = 'application/json'; + + return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/battlefield', 'application/json', $rawBody); + } + + /** @return array */ + private function validBattlefield(): array + { + return [ + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => 8, + 'battlefieldHeight' => 8, + 'battlefieldTerrain' => [], + 'teamA' => [ + 'units' => [ + ['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0], + ['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0], + ['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7], + ['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7], + ['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + } +}