feat: add GetBundledScenario handler with fixture validation
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Http\Handlers;
|
||||||
|
|
||||||
|
use BattleForge\Application\ScenarioSerializer;
|
||||||
|
use BattleForge\Domain\ScenarioValidator;
|
||||||
|
use BattleForge\Http\Request;
|
||||||
|
use BattleForge\Http\Response;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class GetBundledScenario
|
||||||
|
{
|
||||||
|
private const ID_PATTERN = '/^[a-z0-9-]+$/';
|
||||||
|
|
||||||
|
public function __construct(private readonly string $scenariosDir)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Integration;
|
||||||
|
|
||||||
|
use BattleForge\Http\Handlers\GetBundledScenario;
|
||||||
|
use BattleForge\Http\Request;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class GetBundledScenarioTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tmpDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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<string, mixed> */
|
||||||
|
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<array{0: int, 1: int}> $coords
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user