"""End-to-end HTTP tests for undo and retry state revert. These tests drive real turns through the actual routes and the world-state engine, with only the LLM provider mocked. The model banks 10 gold each turn through its ```state delta block. These tests confirm that the adventure's stored gold total stays correct across play, undo, and retry. It was a JavaScript output hook that added the gold until M2 removed campaign scripting; the state machinery under test is the same either way. python -m pytest tests/test_turn_flow_integration.py -v """ import pytest from fastapi import Depends from fastapi.testclient import TestClient from app import auth, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app from app.routers import adventures from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply AI_REPLY = "The torch flickers as you press onward." @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) setup = SessionLocal() user = models.User(is_guest=False, email="tester@example.com") setup.add(user) setup.flush() setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model")) scenario = models.Scenario(user_id=user.id, title="S", stat_schema=GOLD_SCHEMA) setup.add(scenario) setup.flush() adv = models.Adventure(user_id=user.id, title="Cave", scenario_id=scenario.id, world_state={"player": {"hp": 100, "gold": 0}}) setup.add(adv) setup.flush() setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave.")) setup.commit() adv_id, user_id = adv.id, user.id setup.close() # Force a real, non-demo turn that uses the fake provider. ScriptedProvider.replies = [gold_reply(AI_REPLY)] monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None) def _current_user(db=Depends(get_db)): return db.get(models.User, user_id) app.dependency_overrides[auth.get_current_user] = _current_user c = TestClient(app) c.adv_id = adv_id try: yield c finally: app.dependency_overrides.clear() adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) def _gold(adv_id) -> int: """The banked total. Reading one stat rather than the whole section keeps these assertions about the rollback, not about the schema's shape.""" db = SessionLocal() try: player = (db.get(models.Adventure, adv_id).world_state or {}).get("player", {}) return player.get("gold", 0) finally: db.close() def _play(client, type_="do", text="look around"): r = client.post(f"/api/adventures/{client.adv_id}/actions", json={"type": type_, "text": text}) assert r.status_code == 200, r.text return r def test_play_then_undo_reverts_gold(client): assert _gold(client.adv_id) == 0 _play(client) assert _gold(client.adv_id) == 10 r = client.post(f"/api/adventures/{client.adv_id}/undo") assert r.status_code == 200, r.text assert _gold(client.adv_id) == 0 # gold reverted to zero def test_two_turns_then_undo_reverts_only_last(client): _play(client) _play(client) assert _gold(client.adv_id) == 20 client.post(f"/api/adventures/{client.adv_id}/undo") assert _gold(client.adv_id) == 10 # back to after turn 1, not 0 def test_retry_does_not_double_apply_gold(client): _play(client) assert _gold(client.adv_id) == 10 # Before the fix, this produced 20 because the turn's effects ran twice. # Now it stays 10. r = client.post(f"/api/adventures/{client.adv_id}/retry") assert r.status_code == 200, r.text assert _gold(client.adv_id) == 10 def test_retry_then_undo_still_clean(client): _play(client) client.post(f"/api/adventures/{client.adv_id}/retry") assert _gold(client.adv_id) == 10 client.post(f"/api/adventures/{client.adv_id}/undo") assert _gold(client.adv_id) == 0