b6c6ddfbc4
Implements docs/architecture/world_persistence_impl.md §9's backend task: chunk/room/revision Postgres schema (migration 0005), a Python port of the world.md §3.6 binary chunk codec with golden-file tests, a WorldStore service (world.md §5.1 minus flush_dirty/rebuild_index), the public world-content router (rooms/chunks CRUD, optimistic concurrency, batch writes) and the internal read-only API for dedicated servers, and game_servers.py changes: a per-spawn bearer token, --api-url/--server-token args, and a real "ready"-field wait replacing _await_port, which bound the UDP port before content existed and failed open (no error) past its deadline. create_world now auto-creates the "main" room. Deviations/findings, not silently decided: - chunk_codec.py implements real IEEE-754 float32 packing rather than mirroring a confirmed bug in scripts/chunk_data_decoder.gd's _read_float32/_write_float32, which do a lossy int<->float cast instead of a bit reinterpretation (verified against Godot 4.6.2 headless) and will corrupt any non-integer position/rotation/scale/bounds value. Not fixed here (client-side, out of scope); flagged for the client task. - world_persistence_impl.md references a world's monotonic content_revision (§6/§7.2/§8.1) but never defines where it's stored. Resolved as MAX(id) over the append-only world_chunk_revisions table, scoped to world_id, rather than adding an undocumented column. - No existing Godot-side golden fixture/test vectors were found to reuse, so tests/fixtures/chunks/*.bin are authored fresh from this implementation (see generate_fixtures.py). Verification: 132/132 backend pytest tests pass against a real ephemeral Postgres (both reusing an already-running instance and cold via conftest.py's own docker-compose bootstrap), including migration upgrade/downgrade/upgrade, codec round-trip + validation-error tests, and full HTTP round trips for PUT/GET/batch/delete/revisions/rollback and the internal API's token auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
154 lines
6.0 KiB
Python
154 lines
6.0 KiB
Python
"""Unit tests for the game_servers.py changes made for world persistence
|
|
(world_persistence_impl.md §7.3/§7.4): per-spawn token validation, and the
|
|
``_await_ready`` replacement for the old, fail-open ``_await_port``.
|
|
|
|
No real Godot process is spawned here (that stays a manual/checkpoint
|
|
integration test, per test_world_join.py's docstring) -- these exercise
|
|
``GameServerSupervisor`` against a fake process object standing in for
|
|
``subprocess.Popen``.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from app.config import settings
|
|
from app.services.game_servers import GameServerSupervisor, _WorldServer
|
|
|
|
|
|
class _FakeProc:
|
|
"""Stands in for subprocess.Popen: only .poll() is needed by the code
|
|
paths under test here."""
|
|
|
|
def __init__(self, exited: bool = False):
|
|
self._exited = exited
|
|
|
|
def poll(self):
|
|
return 0 if self._exited else None
|
|
|
|
def terminate(self):
|
|
self._exited = True
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
def kill(self):
|
|
self._exited = True
|
|
|
|
|
|
def _make_srv(tmp_path, token="secret", alive=True) -> _WorldServer:
|
|
status_path = tmp_path / "status.json"
|
|
return _WorldServer(
|
|
world_id="7", port=27000, proc=_FakeProc(exited=not alive),
|
|
status_path=status_path, token=token,
|
|
)
|
|
|
|
|
|
class TestValidateToken:
|
|
def test_unknown_world_is_false(self):
|
|
sup = GameServerSupervisor()
|
|
assert sup.validate_token("no-such-world", "anything") is False
|
|
|
|
def test_correct_token_is_true(self, tmp_path):
|
|
sup = GameServerSupervisor()
|
|
sup._servers["7"] = _make_srv(tmp_path, token="secret")
|
|
assert sup.validate_token("7", "secret") is True
|
|
# world_id is looked up as str(world_id), so an int works too --
|
|
# this matters because the FastAPI path param is an int.
|
|
assert sup.validate_token(7, "secret") is True
|
|
|
|
def test_wrong_token_is_false(self, tmp_path):
|
|
sup = GameServerSupervisor()
|
|
sup._servers["7"] = _make_srv(tmp_path, token="secret")
|
|
assert sup.validate_token("7", "wrong") is False
|
|
|
|
def test_token_scoped_to_its_own_world_only(self, tmp_path):
|
|
sup = GameServerSupervisor()
|
|
sup._servers["7"] = _make_srv(tmp_path, token="secret")
|
|
# Same token, different world_id -- must not authenticate (§7.3:
|
|
# "a path whose world_id differs from the token's world is 403").
|
|
assert sup.validate_token("8", "secret") is False
|
|
|
|
def test_dead_server_token_is_false(self, tmp_path):
|
|
sup = GameServerSupervisor()
|
|
sup._servers["7"] = _make_srv(tmp_path, token="secret", alive=False)
|
|
assert sup.validate_token("7", "secret") is False
|
|
|
|
def test_reaping_invalidates_the_token(self, tmp_path):
|
|
"""§7.3: "dies with its process" -- once removed from _servers
|
|
(what the reaper does), the same token no longer validates."""
|
|
sup = GameServerSupervisor()
|
|
sup._servers["7"] = _make_srv(tmp_path, token="secret")
|
|
assert sup.validate_token("7", "secret") is True
|
|
del sup._servers["7"]
|
|
assert sup.validate_token("7", "secret") is False
|
|
|
|
|
|
class TestAwaitReady:
|
|
"""Replaces the old _await_port, which (a) treated a bound UDP port as
|
|
readiness even though content loading happens before create_server, and
|
|
(b) failed open past its deadline with no error at all."""
|
|
|
|
async def test_raises_timeout_error_when_never_ready(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3)
|
|
sup = GameServerSupervisor()
|
|
srv = _make_srv(tmp_path) # status file never written
|
|
with pytest.raises(TimeoutError):
|
|
await sup._await_ready(srv)
|
|
|
|
async def test_returns_once_status_file_reports_ready(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 5.0)
|
|
sup = GameServerSupervisor()
|
|
srv = _make_srv(tmp_path)
|
|
srv.status_path.write_text(json.dumps({"ready": True, "players": 0}))
|
|
await sup._await_ready(srv) # must not raise / must not hang
|
|
|
|
async def test_ready_false_is_not_ready(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3)
|
|
sup = GameServerSupervisor()
|
|
srv = _make_srv(tmp_path)
|
|
srv.status_path.write_text(json.dumps({"ready": False, "players": 0}))
|
|
with pytest.raises(TimeoutError):
|
|
await sup._await_ready(srv)
|
|
|
|
async def test_raises_promptly_if_process_already_exited(self, tmp_path, monkeypatch):
|
|
# A long deadline, but the process is already dead -- must not wait
|
|
# out the full deadline (this is the "no crash restart mid-wait"
|
|
# case: don't silently poll a corpse for 20s).
|
|
monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 20.0)
|
|
sup = GameServerSupervisor()
|
|
srv = _make_srv(tmp_path, alive=False)
|
|
with pytest.raises(TimeoutError):
|
|
await sup._await_ready(srv)
|
|
|
|
|
|
class TestSpawnCommandLine:
|
|
def test_spawn_includes_api_url_and_server_token(self, monkeypatch, tmp_path):
|
|
"""world_persistence_impl.md §7.2 step 1 / §7.4: `_spawn` must pass
|
|
--api-url and --server-token on the command line."""
|
|
sup = GameServerSupervisor()
|
|
monkeypatch.setattr(sup, "_free_port", lambda: 27050)
|
|
|
|
captured = {}
|
|
|
|
class _Proc:
|
|
def poll(self):
|
|
return None
|
|
|
|
def _fake_popen(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
return _Proc()
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.game_servers.subprocess.Popen", _fake_popen
|
|
)
|
|
srv = sup._spawn("42")
|
|
|
|
cmd = captured["cmd"]
|
|
assert "--api-url" in cmd
|
|
assert cmd[cmd.index("--api-url") + 1] == settings.GAME_SERVER_INTERNAL_API_URL
|
|
assert "--server-token" in cmd
|
|
token_in_cmd = cmd[cmd.index("--server-token") + 1]
|
|
assert token_in_cmd == srv.token
|
|
assert len(srv.token) > 20 # secrets.token_urlsafe(32) -- not a short/guessable value
|