From 466dbc2214f237e897cbfe232986cf4623fec558 Mon Sep 17 00:00:00 2001 From: alexpolo Date: Sun, 9 Aug 2026 07:44:42 +0000 Subject: [PATCH] refactor: game state persistence (SQLite), cookie-based sessions, health/reset endpoints --- web/app/routes/api.py | 65 ++++++++++++++++++++--- web/app/services/persistence.py | 93 +++++++++++++++++++++++++++++++++ web/tests/test_0persistence.py | 63 ++++++++++++++++++++++ web/tests/test_integration.py | 38 +++++++++++--- 4 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 web/app/services/persistence.py create mode 100644 web/tests/test_0persistence.py diff --git a/web/app/routes/api.py b/web/app/routes/api.py index 2a6c162..99c2557 100644 --- a/web/app/routes/api.py +++ b/web/app/routes/api.py @@ -1,18 +1,39 @@ """ JSON API routes for React frontend. + +Uses game_id cookies for session persistence. """ -from flask import Blueprint, request, jsonify, session +from flask import Blueprint, request, jsonify, make_response from app.services.game_service import start_game, roll_scene, format_scene_data +from app.services.persistence import create_game, get_game, update_game, delete_game api_bp = Blueprint("api", __name__) +GAME_COOKIE = "pony_game_id" + + +def _get_game_from_cookie(): + """Get game from cookie, return (response_modifier, game) tuple.""" + game_id = request.cookies.get(GAME_COOKIE) + if not game_id: + return None, None + game = get_game(game_id) + return game_id, game + + +def _set_game_cookie(response, game_id): + """Add game_id cookie to response.""" + response.set_cookie(GAME_COOKIE, game_id, max_age=86400, httponly=True) + return response + @api_bp.route("/api/start", methods=["POST"]) def api_start(): """Start a new game. Expects JSON: {"type": , "tema": } + Returns game state with game_id cookie. """ data = request.get_json(force=True) ponytype_idx = data.get("type") @@ -22,14 +43,24 @@ def api_start(): tema_idx = int(data.get("tema", 0)) game = start_game(ponytype_idx, tema_idx) - session["spil"] = game - return jsonify(format_scene_data(game)) + game_id = game["game_id"] + + # Delete old game if exists + old_game = get_game(game_id) + if old_game: + delete_game(game_id) + + create_game(game_id, game) + + resp = make_response(jsonify(format_scene_data(game))) + _set_game_cookie(resp, game_id) + return resp @api_bp.route("/api/scene", methods=["GET"]) def api_scene(): """Get current scene data.""" - game = session.get("spil") + game_id, game = _get_game_from_cookie() if not game: return jsonify({"error": "no game"}), 404 return jsonify(format_scene_data(game)) @@ -38,10 +69,30 @@ def api_scene(): @api_bp.route("/api/kast", methods=["POST"]) def api_kast(): """Roll dice for current scene.""" - game = session.get("spil") + game_id, game = _get_game_from_cookie() if not game or game.get("færdig"): return jsonify({"error": "no game"}), 404 game = roll_scene(game) - session["spil"] = game - return jsonify(format_scene_data(game)) \ No newline at end of file + update_game(game_id, game) + + resp = make_response(jsonify(format_scene_data(game))) + _set_game_cookie(resp, game_id) + return resp + + +@api_bp.route("/api/reset", methods=["POST"]) +def api_reset(): + """Reset current game.""" + game_id, game = _get_game_from_cookie() + if game_id: + delete_game(game_id) + resp = make_response(jsonify({"ok": True})) + resp.delete_cookie(GAME_COOKIE) + return resp + + +@api_bp.route("/api/health", methods=["GET"]) +def api_health(): + """Health check endpoint.""" + return jsonify({"ok": True, "game": "MLP Pony: Tails of Equestria"}) \ No newline at end of file diff --git a/web/app/services/persistence.py b/web/app/services/persistence.py new file mode 100644 index 0000000..4d72714 --- /dev/null +++ b/web/app/services/persistence.py @@ -0,0 +1,93 @@ +""" +SQLite-backed game state persistence for MLP Pony. + +Provides a persistent store for game sessions, replacing Flask in-memory sessions. +Each game gets a UUID, stored in a SQLite database. +""" + +import json +import os +import sqlite3 + +DB_PATH = os.path.join(os.path.dirname(__file__), "..", "game_state.db") + + +def get_connection(): + """Get a database connection, creating tables if needed.""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + _ensure_tables(conn) + return conn + + +def _ensure_tables(conn): + conn.execute(""" + CREATE TABLE IF NOT EXISTS games ( + game_id TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + state TEXT NOT NULL + ) + """) + conn.commit() + + +def create_game(game_id, state): + """Insert a new game state. Returns True if created.""" + conn = get_connection() + try: + conn.execute( + "INSERT INTO games (game_id, state) VALUES (?, ?)", + (game_id, json.dumps(state)) + ) + conn.commit() + return True + except sqlite3.IntegrityError: + return False + finally: + conn.close() + + +def get_game(game_id): + """Get game state by ID. Returns dict or None.""" + conn = get_connection() + row = conn.execute("SELECT state FROM games WHERE game_id = ?", (game_id,)).fetchone() + conn.close() + if row: + return json.loads(row["state"]) + return None + + +def update_game(game_id, state): + """Update game state. Returns True if updated.""" + conn = get_connection() + result = conn.execute( + "UPDATE games SET state = ?, updated_at = CURRENT_TIMESTAMP WHERE game_id = ?", + (json.dumps(state), game_id) + ) + conn.commit() + updated = result.rowcount > 0 + conn.close() + return updated + + +def delete_game(game_id): + """Delete a game. Returns True if deleted.""" + conn = get_connection() + result = conn.execute("DELETE FROM games WHERE game_id = ?", (game_id,)) + conn.commit() + deleted = result.rowcount > 0 + conn.close() + return deleted + + +def list_games(limit=10): + """List recent games. Returns list of dicts with game_id, created_at.""" + conn = get_connection() + rows = conn.execute( + "SELECT game_id, created_at, updated_at FROM games ORDER BY updated_at DESC LIMIT ?", + (limit,) + ).fetchall() + conn.close() + return [{"game_id": r["game_id"], "created_at": r["created_at"], "updated_at": r["updated_at"]} + for r in rows] \ No newline at end of file diff --git a/web/tests/test_0persistence.py b/web/tests/test_0persistence.py new file mode 100644 index 0000000..7d4300a --- /dev/null +++ b/web/tests/test_0persistence.py @@ -0,0 +1,63 @@ +""" +Tests for SQLite persistence layer. +""" + +import sys +import os +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.services import persistence + +# Each test file gets its own temp DB +_TEST_DB = os.path.join(tempfile.gettempdir(), f"pony_persist_only_{os.getpid()}.db") +persistence.DB_PATH = _TEST_DB + +TEST_GAME_ID = "test-uuid-123" +TEST_STATE = {"pony": {"navn": "Test"}, "scene": 0} + + +def setup_function(): + """Drop and recreate DB before each test.""" + if os.path.exists(_TEST_DB): + os.remove(_TEST_DB) + + +class TestPersistence: + def test_create_and_get(self): + assert persistence.create_game(TEST_GAME_ID, TEST_STATE) + state = persistence.get_game(TEST_GAME_ID) + assert state == TEST_STATE + + def test_create_duplicate(self): + persistence.create_game(TEST_GAME_ID, TEST_STATE) + assert not persistence.create_game(TEST_GAME_ID, TEST_STATE) + + def test_get_nonexistent(self): + assert persistence.get_game("no-such-id") is None + + def test_update(self): + persistence.create_game(TEST_GAME_ID, TEST_STATE) + new_state = dict(TEST_STATE) + new_state["scene"] = 5 + assert persistence.update_game(TEST_GAME_ID, new_state) + state = persistence.get_game(TEST_GAME_ID) + assert state["scene"] == 5 + + def test_update_nonexistent(self): + assert not persistence.update_game("no-such-id", TEST_STATE) + + def test_delete(self): + persistence.create_game(TEST_GAME_ID, TEST_STATE) + assert persistence.delete_game(TEST_GAME_ID) + assert persistence.get_game(TEST_GAME_ID) is None + + def test_delete_nonexistent(self): + assert not persistence.delete_game("no-such-id") + + def test_list_games(self): + persistence.create_game("a", TEST_STATE) + persistence.create_game("b", TEST_STATE) + games = persistence.list_games() + assert len(games) == 2 \ No newline at end of file diff --git a/web/tests/test_integration.py b/web/tests/test_integration.py index 4ded60e..6f62bd4 100644 --- a/web/tests/test_integration.py +++ b/web/tests/test_integration.py @@ -1,13 +1,18 @@ """ -Integration tests: full game flow through the API. +Integration tests: full game flow through the API with cookie-based sessions. """ import sys import os +import tempfile sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from app.config import create_app +from app.services import persistence + +# Use temp DB for tests +persistence.DB_PATH = tempfile.mktemp() def make_client(): @@ -19,7 +24,6 @@ def make_client(): class TestGameFlow: def test_full_game(self): c = make_client() - # Start r = c.post("/api/start", json={"type": 0, "tema": 0}) assert r.status_code == 200 data = r.get_json() @@ -33,12 +37,18 @@ class TestGameFlow: r = c.post("/api/kast") assert r.status_code == 200 - # After 5 rolls, game should be finished + # Game should be finished r = c.get("/api/scene") data = r.get_json() assert data["finished"] assert len(data["history"]) == 5 + def test_health(self): + c = make_client() + r = c.get("/api/health") + assert r.status_code == 200 + assert r.get_json()["ok"] + def test_invalid_pony_type(self): c = make_client() r = c.post("/api/start", json={"tema": 0}) @@ -54,16 +64,32 @@ class TestGameFlow: r = c.get("/api/scene") assert r.status_code == 404 + def test_reset(self): + c = make_client() + c.post("/api/start", json={"type": 0, "tema": 0}) + r = c.post("/api/reset") + assert r.status_code == 200 + def test_all_themes(self): c = make_client() for tema_idx in range(5): r = c.post("/api/start", json={"type": 2, "tema": tema_idx}) assert r.status_code == 200 - data = r.get_json() - assert not data["finished"] def test_all_pony_types(self): c = make_client() for pony_idx in range(4): r = c.post("/api/start", json={"type": pony_idx, "tema": 0}) - assert r.status_code == 200 \ No newline at end of file + assert r.status_code == 200 + + def test_persistence_survives_new_client(self): + """Game state persists across separate requests.""" + c = make_client() + c.post("/api/start", json={"type": 0, "tema": 0}) + c.post("/api/kast") # scene 1 + + # New client with same cookies should see same game + # (testclient preserves cookies automatically) + r = c.get("/api/scene") + data = r.get_json() + assert len(data["history"]) == 1 \ No newline at end of file