Files
interactive-story/backend/tests/test_scenario_art.py
parththakkar106andClaude Opus 5 f1bebe18d0 Drop the eight legacy columns SP8 left behind
Migrations 66 to 73 drop `actions.index`, `variants`, `variant_index`,
`variant_count`, `state_before`, and `world_state_before`, plus
`adventures.memory_cursor` and `summary_cursor`. `index` is a keyword in
SQLite, so migration 71 quotes it.

Nothing outside the migrations read these. `models.py`, `schemas.py`, and
`ACTION_LIST_COLUMNS` lose the same eight fields, `Adventure.actions` orders
by `id`, and `attempts.renumber`, `context.history.max_action_index`, and
`nodes.next_index` are deleted.

Two changes keep the migration replayable on a `create_all` database:

- `_split_variants_into_siblings` wrote through the live ORM table, so it
  stopped compiling once migration 66 removed five of its columns. It now
  writes through `_ACTIONS_AT_60`, a frozen `Table` with its own `MetaData`.
- Five data passes read columns these migrations drop. Each now calls
  `_has_columns` and returns early when the columns are absent.

`bootstrap` takes a `through` version so a migration test can stop at the
schema it asserts on.

555 tests pass, up from 549. Eight of the new cases assert each column is
gone after a real schema-45 database migrates all the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198qDK3gmgSo7EtQ4GTPqqK
2026-08-29 17:15:49 +05:30

206 lines
7.8 KiB
Python

"""Scenario cover art and the Continue-card snippet.
Unit tests for the data-URI handling in app/images.py. Then HTTP tests
confirm that the list endpoints advertise a cacheable `image_url` instead
of the inline base64, that the image route serves real bytes, and that an
adventure's snippet comes from the latest narration rather than the
player's last line.
python -m pytest tests/test_scenario_art.py -v
"""
import base64
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, images, limits, models, schemas
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
# Smallest valid PNG: a single transparent pixel.
PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=="
)
PNG_URI = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
# --------------------------------------------------------------------------- #
# app/images.py
# --------------------------------------------------------------------------- #
def test_decode_returns_bytes_and_content_type():
assert images.decode(PNG_URI) == (PNG_BYTES, "image/png")
def test_decode_tolerates_wrapped_base64():
"""A hand-pasted URI can carry newlines. `b64decode(validate=True)` rejects them."""
wrapped = "data:image/png;base64," + "\n".join(
base64.b64encode(PNG_BYTES).decode()[i:i + 24] for i in range(0, 100, 24)
)
# This test only confirms that the call does not raise and does not
# silently return a partial decode of a truncated payload. The wrapped
# prefix here is not the whole image, so a None result is also
# acceptable. What matters is that no exception occurs.
images.decode(wrapped)
def test_decode_rejects_non_data_uris_and_garbage():
assert images.decode("https://example.com/a.png") is None
assert images.decode("data:image/png;base64,!!!not base64!!!") is None
assert images.decode("") is None
assert images.decode(None) is None
def test_decode_rejects_svg():
"""SVG can carry script, and the app serves these bytes from its own origin."""
svg = "data:image/svg+xml;base64," + base64.b64encode(b"<svg/>").decode()
assert images.decode(svg) is None
def test_public_url_points_at_the_endpoint_for_data_uris():
class Stamp:
def timestamp(self):
return 1700000000.0
assert images.public_url(7, PNG_URI, Stamp()) == "/api/scenarios/7/image?v=1700000000"
def test_public_url_passes_through_https_but_not_http():
stamp = None
assert images.public_url(1, "https://cdn.example.com/a.png", stamp) == \
"https://cdn.example.com/a.png"
# http:// would be blocked as mixed content on the deployed https app.
assert images.public_url(1, "http://cdn.example.com/a.png", stamp) == ""
assert images.public_url(1, "", stamp) == ""
def test_sanitize_rejects_hostile_and_oversized_values():
assert images.sanitize("javascript:alert(1)", 1000) == ""
assert images.sanitize("http://example.com/a.png", 1000) == ""
assert images.sanitize(PNG_URI, len(PNG_URI) - 1) == "" # over the cap
assert images.sanitize(12345, 1000) == ""
assert images.sanitize(None, 1000) == ""
# Well-formed values survive.
assert images.sanitize(PNG_URI, schemas.IMAGE_MAX) == PNG_URI
assert images.sanitize("https://example.com/a.png", 1000) == "https://example.com/a.png"
# --------------------------------------------------------------------------- #
# HTTP
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="art@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
# One scenario with an uploaded picture, one with only an emoji.
pictured = models.Scenario(user_id=user.id, title="Pictured", image=PNG_URI)
emoji_only = models.Scenario(user_id=user.id, title="Emoji", icon="🐉")
setup.add_all([pictured, emoji_only])
setup.flush()
adventure = models.Adventure(
user_id=user.id, scenario_id=pictured.id, title="A Run",
)
setup.add(adventure)
setup.flush()
# A full turn: opening narration, the player's line, then the AI's reply.
setup.add_all([
models.Action(adventure_id=adventure.id, type="ai",
text="The door groans open."),
models.Action(adventure_id=adventure.id, type="do",
text="I draw my sword."),
models.Action(adventure_id=adventure.id, type="ai",
text="Steel rings. The\ncorridor answers."),
])
setup.commit()
ids = {"scenario": pictured.id, "emoji": emoji_only.id, "adventure": adventure.id}
user_id = user.id
setup.close()
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.ids = ids
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def test_scenario_list_advertises_a_url_and_hides_the_base64(client):
rows = {row["title"]: row for row in client.get("/api/scenarios").json()}
pictured = rows["Pictured"]
assert pictured["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=")
# A list response must never carry the inline image.
assert "image" not in pictured
assert rows["Emoji"]["image_url"] == ""
assert rows["Emoji"]["icon"] == "🐉"
def test_image_endpoint_serves_the_decoded_bytes(client):
resp = client.get(f"/api/scenarios/{client.ids['scenario']}/image")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
assert resp.content == PNG_BYTES
assert "immutable" in resp.headers["cache-control"]
def test_image_endpoint_404s_without_an_upload(client):
resp = client.get(f"/api/scenarios/{client.ids['emoji']}/image")
assert resp.status_code == 404
def test_single_scenario_still_returns_the_raw_uri_for_editing(client):
"""The editor needs the actual value to preview and to clear."""
body = client.get(f"/api/scenarios/{client.ids['scenario']}").json()
assert body["image"] == PNG_URI
def test_adventure_list_carries_snippet_and_inherited_art(client):
row = next(r for r in client.get("/api/adventures").json()
if r["id"] == client.ids["adventure"])
# Latest narration, whitespace collapsed. Not the player's line, "I draw my sword."
assert row["snippet"] == "Steel rings. The corridor answers."
assert row["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=")
assert row["action_count"] == 3
def test_snippet_is_truncated_on_a_word_boundary(client):
from app.routers.adventures import SNIPPET_MAX, _snippet
long_text = "word " * 200
out = _snippet(long_text)
assert len(out) <= SNIPPET_MAX + 1 # +1 for the ellipsis
assert out.endswith("…")
assert "wor…" not in out # never cuts mid-word
def test_scenario_export_import_round_trips_the_art(client):
bundle = client.get(f"/api/scenarios/{client.ids['scenario']}/export").json()
assert bundle["image"] == PNG_URI
created = client.post("/api/scenarios/import", json=bundle).json()["scenario"]
assert created["image"] == PNG_URI
def test_import_drops_a_hostile_image_value(client):
bundle = {"title": "Sneaky", "image": "javascript:alert(1)"}
created = client.post("/api/scenarios/import", json=bundle).json()["scenario"]
assert created["image"] == ""