Every test module carried the same eight-line prologue redirecting the database to a temp file. Only the first one to be imported ever took effect: `app.database` reads `AIDND_DB_PATH` at import and builds `engine` from it once, so by the time the second module ran the engine already existed. The other 34 copies created a temp file that nothing opened and nothing deleted, and leaked one per module per run. `conftest.py` now does it once, which is early enough because pytest imports conftest before any test module. It also deletes the file when the run ends. The tests still share one database, exactly as they already did: each `client` fixture calls `create_all` on setup and `drop_all` on teardown, so no test sees another test's rows. `tests/fakes.py` holds the one `ScriptedProvider`. Nine modules each had a copy, and the copies had drifted into four feature sets, so a test that needed to raise a provider error had to be written in one of the files whose copy supported that. The shared one is the superset. The two `FakeProvider` copies were the same class with a fixed reply, so they use it too. `test_chat.py` keeps its own, which implements `chat` rather than `generate` and records what it was constructed with. An autouse fixture resets the fake's class state between tests, so a stale reply list can no longer reach the next test. 435 lines out of the suite. 549 tests pass. Verified live by sabotage: breaking the shared fake fails 13 tests across four modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
126 lines
3.9 KiB
Python
126 lines
3.9 KiB
Python
"""End-to-end HTTP tests for undo and retry state revert. These tests drive
|
|
real turns through the actual routes and scripting engine, with only the LLM
|
|
provider mocked.
|
|
|
|
A script's output hook adds 10 gold each turn. These tests confirm that the
|
|
adventure's stored gold total stays correct across play, undo, and retry.
|
|
|
|
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 ScriptedProvider
|
|
|
|
GOLD_SCRIPT = """
|
|
const modifier = (text) => {
|
|
state.gold = (state.gold || 0) + 10;
|
|
return { text };
|
|
};
|
|
modifier(text);
|
|
"""
|
|
|
|
|
|
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"))
|
|
adv = models.Adventure(user_id=user.id, title="Cave", script_state={})
|
|
setup.add(adv)
|
|
setup.flush()
|
|
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
|
|
setup.add(models.AdventureScript(
|
|
adventure_id=adv.id, position=0, enabled=True, name="Gold",
|
|
output_js=GOLD_SCRIPT,
|
|
))
|
|
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 = [AI_REPLY]
|
|
monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider)
|
|
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
|
"http://fake", "k", "test-model", False))
|
|
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
|
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._active_turns.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
def _state(adv_id):
|
|
db = SessionLocal()
|
|
try:
|
|
return db.get(models.Adventure, adv_id).script_state
|
|
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 _state(client.adv_id) == {}
|
|
_play(client)
|
|
assert _state(client.adv_id) == {"gold": 10}
|
|
|
|
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
assert r.status_code == 200, r.text
|
|
assert _state(client.adv_id) == {} # gold reverted to zero
|
|
|
|
|
|
def test_two_turns_then_undo_reverts_only_last(client):
|
|
_play(client)
|
|
_play(client)
|
|
assert _state(client.adv_id) == {"gold": 20}
|
|
|
|
client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
assert _state(client.adv_id) == {"gold": 10} # back to after turn 1, not 0
|
|
|
|
|
|
def test_retry_does_not_double_apply_gold(client):
|
|
_play(client)
|
|
assert _state(client.adv_id) == {"gold": 10}
|
|
|
|
# Before the fix, this produced 20 because the output hook ran twice.
|
|
# Now it stays 10.
|
|
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
|
assert r.status_code == 200, r.text
|
|
assert _state(client.adv_id) == {"gold": 10}
|
|
|
|
|
|
def test_retry_then_undo_still_clean(client):
|
|
_play(client)
|
|
client.post(f"/api/adventures/{client.adv_id}/retry")
|
|
assert _state(client.adv_id) == {"gold": 10}
|
|
client.post(f"/api/adventures/{client.adv_id}/undo")
|
|
assert _state(client.adv_id) == {}
|