Files
BattleForge/tests/Integration/PostMatchActionSmokeTest.php
T
Keith Solomon 44199ec0a8
CI / php (push) Failing after 1m11s
chore: address Plan 4 deferred-minors (stale-cookie cleanup pass)
- Rename $secret to $csrfSecret in public/index.php to make
  intent clear at every call site; the value is the CSRF/turn-token
  HMAC key, not just 'the secret'.
- Drop the stale 'Configure the router with the eight routes' comment
  in public/index.php; the router now has sixteen routes.
- Drop the duplicate .bf-toast rule in public/assets/styles.css; the
  second declaration (the new yellow box) is the active one, the
  first (legacy green pill) is dead code from the editor era.
- Add a trailing newline to src/Views/home.php.
- Tighten the matchId regex in two tests from {16,} to {16} since
  bin2hex(random_bytes(8)) always produces exactly 16 hex chars; the
  spec's {16,} stays in production TurnToken.

No behavior changes; addresses the deferred-minors parked during
per-task review.
2026-07-26 16:38:05 -05:00

369 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Application\TurnToken;
use PHPUnit\Framework\TestCase;
/**
* End-to-end smoke test. Boots a real `php -S` dev server and walks the
* Skirmish bundled scenario to a winner. Gated by BATTLEFORGE_E2E=1 so the
* per-commit CI does not pay its cost.
*/
final class PostMatchActionSmokeTest extends TestCase
{
private const SECRET = 'e2e-smoke-secret';
private const BASE_URL = 'http://127.0.0.1:8765';
private const CSRF_COOKIE_NAME = '__csrf';
/** @var resource|null */
private $serverProcess = null;
private string $csrfToken = '';
private string $csrfCookie = '';
protected function setUp(): void
{
if (getenv('BATTLEFORGE_E2E') !== '1') {
self::markTestSkipped('Set BATTLEFORGE_E2E=1 to run the end-to-end smoke test.');
}
$this->serverProcess = $this->bootServer();
$this->primeCsrf();
}
protected function tearDown(): void
{
if (is_resource($this->serverProcess)) {
proc_terminate($this->serverProcess, 9);
proc_close($this->serverProcess);
}
@unlink(__DIR__ . '/../../var/secret.key');
}
public function testHomePageReturnsTheBundledScenariosList(): void
{
$body = $this->get('/');
self::assertStringContainsString('id="bf-bundled"', $body);
self::assertStringContainsString('data-bf-bundle="skirmish"', $body);
}
public function testSkirmishScenarioStartsAndCanBePlayedToAWinner(): void
{
$scenario = $this->getJson('/scenarios/bundled/skirmish');
self::assertSame('skirmish', $scenario['id'] ?? null);
$startResponse = $this->postJson('/scenarios/skirmish/start', $scenario);
self::assertSame(200, $startResponse['status']);
$match = $startResponse['body']['match'] ?? null;
$matchId = $startResponse['body']['matchId'] ?? null;
$turnToken = $startResponse['body']['turnToken'] ?? null;
self::assertIsArray($match);
self::assertIsString($matchId);
self::assertMatchesRegularExpression('/^[a-f0-9]{16}$/', $matchId);
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $turnToken);
for ($i = 0; $i < 200; $i += 1) {
if (!empty($match['winnerTeamId'])) {
break;
}
$action = $this->pickAction($match, $matchId);
$response = $this->postJson("/matches/current/{$action['verb']}", $action['body']);
$errMsg = "Step {$i} verb={$action['verb']} failed: " . ($response['body']['error'] ?? '');
self::assertSame(200, $response['status'], $errMsg);
$match = $response['body']['match'];
$turnToken = $response['body']['turnToken'];
}
self::assertNotNull($match['winnerTeamId'] ?? null, 'Skirmish did not produce a winner within 200 actions.');
}
public function testStaleTurnTokenReturns409(): void
{
$scenario = $this->getJson('/scenarios/bundled/skirmish');
$startResponse = $this->postJson('/scenarios/skirmish/start', $scenario);
self::assertSame(200, $startResponse['status']);
$match = $startResponse['body']['match'];
$matchId = $startResponse['body']['matchId'];
$staleToken = TurnToken::issue(self::SECRET, $matchId, 'alpha', 99, 0);
$response = $this->postJson('/matches/current/end-turn', [
'matchId' => $matchId,
'match' => $match,
], $staleToken);
self::assertSame(409, $response['status']);
self::assertSame('turn', $response['body']['error'] ?? null);
}
public function testBundledHoldTheLineAndLastStandStartMatches(): void
{
foreach (['hold-the-line', 'last-stand'] as $id) {
$scenario = $this->getJson("/scenarios/bundled/{$id}");
$response = $this->postJson("/scenarios/{$id}/start", $scenario);
self::assertSame(200, $response['status'], "Failed to start {$id}");
self::assertSame(
'alpha',
$response['body']['match']['activeTeamId'] ?? null,
"{$id} did not start with alpha",
);
self::assertNotEmpty($response['body']['matchId']);
self::assertNotEmpty($response['body']['turnToken']);
}
}
/** @return resource */
private function bootServer()
{
$this->writeSecret();
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$env = [
'PATH' => getenv('PATH') ?: '',
'BATTLEFORGE_SECRET' => self::SECRET,
];
$process = proc_open(
['php', '-S', '127.0.0.1:8765', '-t', __DIR__ . '/../../public'],
$descriptors,
$pipes,
__DIR__ . '/../..',
$env,
);
if (!is_resource($process)) {
self::fail('Failed to start php -S');
}
// Wait for the server to print "PHP X.Y.Z Development Server (...) started"
$start = microtime(true);
while (microtime(true) - $start < 5.0) {
$line = fgets($pipes[1]);
if ($line !== false && str_contains($line, 'started')) {
break;
}
usleep(50_000);
}
return $process;
}
private function writeSecret(): void
{
@unlink(__DIR__ . '/../../var/secret.key');
$dir = __DIR__ . '/../../var';
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
// We don't need an actual random secret because BATTLEFORGE_SECRET env wins.
// Just touch the file to make sure the dir exists.
file_put_contents($dir . '/secret.key', 'unused-because-env-wins');
}
private function primeCsrf(): void
{
$ctx = stream_context_create(['http' => ['ignore_errors' => true, 'header' => '']]);
$body = (string) file_get_contents(self::BASE_URL . '/', false, $ctx);
$headers = (string) ($http_response_header[0] ?? '');
foreach ($http_response_header as $line) {
if (preg_match('/^Set-Cookie:\\s*' . self::CSRF_COOKIE_NAME . '=([^;]+)/i', $line, $m)) {
$this->csrfCookie = $m[1];
$this->csrfToken = $this->csrfCookie;
return;
}
}
self::fail('Could not prime CSRF cookie: ' . $body);
}
/** @return array<string, mixed> */
private function getJson(string $path): array
{
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n",
'ignore_errors' => true,
],
]);
$body = (string) file_get_contents(self::BASE_URL . $path, false, $ctx);
$data = json_decode($body, true);
if (!is_array($data)) {
self::fail("GET {$path} did not return JSON: {$body}");
}
return $data;
}
private function get(string $path): string
{
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n",
'ignore_errors' => true,
],
]);
return (string) file_get_contents(self::BASE_URL . $path, false, $ctx);
}
/**
* @param array<string, mixed> $body
* @return array{status: int, body: array<string, mixed>}
*/
private function postJson(string $path, array $body, ?string $turnToken = null): array
{
$headers = [
'Content-Type: application/json',
'X-CSRF-Token: ' . $this->csrfToken,
'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie,
];
if ($turnToken !== null) {
$headers[] = 'X-Turn-Token: ' . $turnToken;
} elseif (isset($body['match'])) {
$match = $body['match'];
$matchId = (string) ($body['matchId'] ?? '');
$turnToken = TurnToken::issue(
self::SECRET,
$matchId,
(string) ($match['activeTeamId'] ?? 'alpha'),
(int) ($match['round'] ?? 1),
count((array) ($match['actionLog'] ?? [])),
);
$headers[] = 'X-Turn-Token: ' . $turnToken;
}
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => json_encode($body, JSON_THROW_ON_ERROR),
'ignore_errors' => true,
],
]);
$response = (string) file_get_contents(self::BASE_URL . $path, false, $ctx);
$status = 0;
foreach ($http_response_header as $line) {
if (preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) {
$status = (int) $m[1];
break;
}
}
$decoded = json_decode($response, true);
return ['status' => $status, 'body' => is_array($decoded) ? $decoded : ['raw' => $response]];
}
/**
* Pick a sensible action for the current match. The strategy:
* - if any active unit is adjacent to an enemy, attack.
* - otherwise move toward the nearest enemy.
* - if no enemy is reachable this turn, end the turn.
*
* @param array<string, mixed> $match
* @param string $matchId
* @return array{verb: string, body: array<string, mixed>}
*/
private function pickAction(array $match, string $matchId): array
{
$units = $match['units'];
$active = $match['activeTeamId'];
$enemy = $active === 'alpha' ? 'bravo' : 'alpha';
foreach ($units as $unit) {
if ($unit['teamId'] !== $active || $unit['health'] <= 0) {
continue;
}
foreach ($units as $other) {
if ($other['teamId'] !== $enemy || $other['health'] <= 0) {
continue;
}
$dist = abs($unit['position']['x'] - $other['position']['x'])
+ abs($unit['position']['y'] - $other['position']['y']);
if ($dist === 1 && $unit['actionsRemaining'] > 0 && empty($unit['hasAttacked'])) {
return [
'verb' => 'attack',
'body' => [
'matchId' => $matchId,
'match' => $match,
'attackerId' => $unit['id'],
'targetId' => $other['id'],
],
];
}
}
}
foreach ($units as $unit) {
if ($unit['teamId'] !== $active || $unit['health'] <= 0 || $unit['actionsRemaining'] === 0) {
continue;
}
$bestTarget = null;
$bestDist = PHP_INT_MAX;
foreach ($units as $other) {
if ($other['teamId'] !== $enemy || $other['health'] <= 0) {
continue;
}
$dist = abs($unit['position']['x'] - $other['position']['x'])
+ abs($unit['position']['y'] - $other['position']['y']);
if ($dist < $bestDist) {
$bestDist = $dist;
$bestTarget = $other;
}
}
if ($bestTarget) {
$dx = $bestTarget['position']['x'] - $unit['position']['x'];
$dy = $bestTarget['position']['y'] - $unit['position']['y'];
$stepX = $dx === 0 ? 0 : ($dx > 0 ? 1 : -1);
$stepY = $dy === 0 ? 0 : ($dy > 0 ? 1 : -1);
$candidates = [];
if ($stepX !== 0) {
$candidates[] = [
'x' => $unit['position']['x'] + $stepX,
'y' => $unit['position']['y'],
];
}
if ($stepY !== 0) {
$candidates[] = [
'x' => $unit['position']['x'],
'y' => $unit['position']['y'] + $stepY,
];
}
$candidates[] = [
'x' => $unit['position']['x'] + ($stepX ?: 0),
'y' => $unit['position']['y'] + ($stepY ?: 0),
];
foreach ($candidates as $c) {
$inBounds = $c['x'] >= 0 && $c['x'] < $match['battlefield']['width']
&& $c['y'] >= 0 && $c['y'] < $match['battlefield']['height'];
if (!$inBounds) {
continue;
}
$occupied = false;
foreach ($units as $other) {
if ($other['id'] === $unit['id'] || $other['health'] <= 0) {
continue;
}
if ($other['position']['x'] === $c['x'] && $other['position']['y'] === $c['y']) {
$occupied = true;
break;
}
}
if ($occupied) {
continue;
}
return [
'verb' => 'move',
'body' => [
'matchId' => $matchId,
'match' => $match,
'unitId' => $unit['id'],
'x' => $c['x'],
'y' => $c['y'],
],
];
}
}
}
return [
'verb' => 'end-turn',
'body' => [
'matchId' => $matchId,
'match' => $match,
],
];
}
}