Files
qtalker---/backend/tests/test_ws_session.py
Indiana d37bb71e5d feat: the lens — the camera as a channel the dead look through
A new séance mode. The seeker opens their camera, presses "let it look",
and the entity speaks about what is ACTUALLY in the room — the configured
chat model (minicpm-v4.5:8b) is vision-capable, so this is real perception,
not invented description. Same principle as every other channel here: real
measurement first, interpretation second.

Verified live end-to-end through the real WebSocket: given a synthetic room
(pale doorway, red flame on dark boards), "Bessie L. Carter" reported the
gray rectangle and red square on a dark surface with faint shadows, then
misread it as her pen feeling heavy the night before Mr. Edgerton's birdseed
arrived. Accuracy followed by wrongness, which is the whole effect.

Privacy is the load-bearing design constraint, not a footnote:
- "Camera open" and "the entity saw something" are deliberately separate
  states. Opening the lens transmits NOTHING; only an explicit press sends
  one still. There is no timer and no background capture path.
- Frames are downscaled to 768px and JPEG-compressed client-side, then
  passed to the model and dropped. Never written to disk, never logged,
  never attached to an event row — only the resulting utterance is stored,
  exactly like any other thing a spirit says.
- The prompt forbids describing faces or guessing anyone's identity, age or
  appearance; a person present is spoken of only as a presence.
- A closed lens is covered by an opaque veil in the UI, so there is never
  ambiguity about whether the camera is live.

Robustness:
- CameraEye carries the same generation guard the EVP listener needed:
  closing during the permission prompt releases the late-arriving stream
  instead of letting the camera go live after teardown.
- Failures are classified (denied / insecure / absent / busy / unknown)
  rather than always blaming the seeker for a refusal.
- Scrying is the heaviest request this app makes of a CPU-only Ollama box,
  so it gets the tightest limiter of any channel (4/min/user, 8/min/IP).
- Frames are size-capped BEFORE reaching the queue, and a vision failure
  emits an error frame instead of killing the socket — both covered by
  tests asserting the model was never called.

10 new frontend tests, 5 new backend tests. 385 frontend + backend suites
pass; i18n parity holds across both languages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 02:08:02 +00:00

648 lines
25 KiB
Python

import asyncio
import uuid
import pytest
from sqlalchemy import select
import app.ws
from app.entities import fallback_profile
from app.inventory import SUMMON_ESSENCE_TRICKLE
from app.models.contact_session import ContactSession
from app.models.event import Event
from app.models.inventory_item import InventoryItem
from app.models.user import User
from app.rate_limit import RateLimiter
class FakeSpiritService:
async def mint_profile(
self, signature, channel, anomalies, language="en", entropy=None, sky=None
):
return fallback_profile(signature)
async def fragment(self, source, anomaly, language="en"):
return "listen"
async def wire_whisper(self, telemetry, language="en"):
return "the wire hums"
def chat_stream(self, entity, question, history, language="en"):
async def gen():
for token in ["I ", "am ", "here."]:
yield token
return gen()
def ambient_ready(self):
return False
async def _fake_synth(text, voice, profile, instability=0.0):
return b"RIFFfake wav bytes"
@pytest.fixture(autouse=True)
def _fake_spirits(monkeypatch):
monkeypatch.setattr(app.ws, "spirit_service", FakeSpiritService())
monkeypatch.setattr(app.ws, "synthesize_spirit_voice", _fake_synth)
def _read_until(ws, msg_type, max_frames=30, **match):
for _ in range(max_frames):
frame = ws.receive_json()
if frame.get("type") != msg_type:
continue
if all(frame.get(key) == value for key, value in match.items()):
return frame
raise AssertionError(f"never saw frame of type {msg_type!r} matching {match!r}")
def _login(sync_client, username="wsmedium"):
sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
return sync_client.cookies.get("qm_session")
def _ws_connect(sync_client, token):
# TestClient upgrades over ws:// (insecure), so the jar withholds the
# Secure qm_session cookie. Pass it explicitly — real browsers on https
# send it on the upgrade automatically.
return sync_client.websocket_connect(
"/ws/session", headers={"cookie": f"qm_session={token}"}
)
def test_websocket_requires_authentication(sync_client):
with pytest.raises(Exception):
with sync_client.websocket_connect("/ws/session"):
pass
@pytest.mark.asyncio
async def test_websocket_ping_pong_and_session_lifecycle(sync_client, db_session):
_login(sync_client)
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "ping"})
assert _read_until(ws, "pong") == {"type": "pong"}
sessions = (await db_session.execute(select(ContactSession))).scalars().all()
assert len(sessions) == 1
assert sessions[0].ended_at is None
# The server marks the session ended in its disconnect handler; give the
# portal loop a moment to commit before asserting.
for _ in range(40):
await asyncio.sleep(0.05)
db_session.expire_all()
sessions = (await db_session.execute(select(ContactSession))).scalars().all()
if sessions[0].ended_at is not None:
break
assert sessions[0].ended_at is not None
@pytest.mark.asyncio
async def test_summon_mints_entity_and_greets(sync_client, db_session):
_login(sync_client, "summoner")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
entity_frame = _read_until(ws, "entity")
assert entity_frame["is_new"] is True
assert entity_frame["entity"]["name"]
assert entity_frame["entity"]["rarity"] in ("common", "uncommon", "rare", "mythic")
assert entity_frame["entity"]["voice"]["voice_id"]
greeting = _read_until(ws, "utterance")
assert greeting["kind"] == "greeting"
assert greeting["text"]
sessions = (await db_session.execute(select(ContactSession))).scalars().all()
assert sessions[0].entity_id is not None
@pytest.mark.asyncio
async def test_question_streams_reply_and_records_history(sync_client, db_session):
_login(sync_client, "seeker")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "question", "text": "Are you at peace?"})
_read_until(ws, "entity") # auto-summoned before answering
reply_start = _read_until(ws, "reply_start")
assert 0.05 <= reply_start["stability"] <= 0.98
reply_end = _read_until(ws, "reply_end")
assert reply_end["text"] == "I am here."
events = (await db_session.execute(select(Event).order_by(Event.created_at))).scalars().all()
kinds = [event.kind for event in events]
assert "question" in kinds
assert "reply" in kinds
reply_event = next(event for event in events if event.kind == "reply")
assert reply_event.text == "I am here."
@pytest.mark.asyncio
async def test_anomalies_attune_then_produce_fragments(sync_client):
_login(sync_client, "listener")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for i in range(3):
ws.send_json(
{"type": "anomaly", "source": "radio", "frequency": 101.1 + i, "magnitude": 6.5}
)
entity_frame = _read_until(ws, "entity")
assert entity_frame["is_new"] is True
ws.send_json({"type": "anomaly", "source": "radio", "frequency": 104.0, "magnitude": 7.1})
fragment = _read_until(ws, "utterance", kind="fragment")
assert fragment["text"] == "listen"
@pytest.mark.asyncio
async def test_familiar_presence_answers_again_on_a_known_channel(sync_client, monkeypatch):
"""The Codex mechanic: a channel's known spirit is re-contactable.
Whether it answers is a genuine draw against physical entropy
(`ws.RETURN_CHANCE`), so this pins the probability to 1.0 rather than
relying on the default — at 0.72 this assertion would otherwise pass
only ~72% of the time, which is worse than failing.
It ALSO pins VEIL_THINNESS_PULL to 0. The real return chance is
`RETURN_CHANCE * (1 - veil_thinness * PULL)`, and veil_thinness is
driven by the *actual current moon phase and geomagnetic Kp*. On a
full-moon test run, thinness ≈ 1 drags even a pinned RETURN_CHANCE=1.0
down to ~0.55 — so without this, the test is silently date-dependent
and fails ~45% of the time in the wrong week. The veil-thinness
influence has its own dedicated tests; this one isolates re-contact.
"""
# Scoped limiters: the module-level ones are shared singletons that
# accumulate across the whole session, and this test summons more than
# once. Without this it silently eats the per-IP budget that
# test_summon_rate_limited_* depends on, making *those* tests hang
# waiting for an entity frame that was rate-limited away.
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
)
monkeypatch.setattr(app.ws, "RETURN_CHANCE", 1.0)
monkeypatch.setattr(app.ws, "VEIL_THINNESS_PULL", 0.0)
_login(sync_client, "mediumx")
anomalies = [
{"type": "anomaly", "source": "radio", "frequency": 101.0 + i, "magnitude": 5.0 + i}
for i in range(4)
]
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for anomaly in anomalies:
ws.send_json(anomaly)
first = _read_until(ws, "entity")["entity"]["name"]
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for anomaly in anomalies:
ws.send_json(anomaly)
second_frame = _read_until(ws, "entity")
assert second_frame["entity"]["name"] == first
assert second_frame["is_new"] is False
assert second_frame["entity"]["contact_count"] == 2
@pytest.mark.asyncio
async def test_something_else_can_answer_a_known_channel(sync_client, monkeypatch):
"""The point of the entropy rework: contact is not a database lookup.
With the return draw forced to fail, calling into a channel that
already holds a spirit mints a *different* one rather than handing back
the same row — and the newcomer gets its own signature, since the
column is unique and the original still holds the base string.
"""
# Scoped limiters: the module-level ones are shared singletons that
# accumulate across the whole session, and this test summons more than
# once. Without this it silently eats the per-IP budget that
# test_summon_rate_limited_* depends on, making *those* tests hang
# waiting for an entity frame that was rate-limited away.
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
)
_login(sync_client, "channel-crosser")
anomalies = [
{"type": "anomaly", "source": "radio", "frequency": 88.0 + i, "magnitude": 9.0 + i}
for i in range(4)
]
monkeypatch.setattr(app.ws, "RETURN_CHANCE", 1.0)
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for anomaly in anomalies:
ws.send_json(anomaly)
first = _read_until(ws, "entity")["entity"]
# Same room, same anomalies — but this time nothing familiar picks up.
monkeypatch.setattr(app.ws, "RETURN_CHANCE", 0.0)
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for anomaly in anomalies:
ws.send_json(anomaly)
second = _read_until(ws, "entity")
assert second["is_new"] is True
assert second["entity"]["id"] != first["id"]
@pytest.mark.asyncio
async def test_summon_rate_limited_per_account(sync_client, monkeypatch):
# Swap in a tight, test-scoped limiter so this doesn't depend on (or
# pollute) the shared module-level budget other tests draw from.
monkeypatch.setattr(
app.ws, "summon_limiter", RateLimiter(max_requests=2, window_seconds=60)
)
_login(sync_client, "account-limited")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for _ in range(2):
ws.send_json({"type": "summon"})
_read_until(ws, "entity")
_read_until(ws, "utterance", kind="greeting")
ws.send_json({"type": "summon"})
rejection = _read_until(ws, "error")
assert rejection["code"] == "rate_limited"
assert rejection["message"] == (
"The veil is crowded. The spirits need a moment before another summoning."
)
@pytest.mark.asyncio
async def test_summon_rate_limited_per_ip_even_with_fresh_account(
sync_client, monkeypatch
):
# Starve only the IP bucket; the per-account limiter stays at its
# production default so each account below has plenty of its own budget
# left. This proves the IP limiter alone can reject a request — the
# gap the per-account-only limiters left open.
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=1, window_seconds=60)
)
_login(sync_client, "ip-limited-a")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
_read_until(ws, "entity") # spends the single per-IP slot
# A different account — its own per-account budget is untouched — but
# every connection in this test shares the same (simulated) source IP,
# which is already spent.
_login(sync_client, "ip-limited-b")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
rejection = _read_until(ws, "error")
assert rejection["code"] == "rate_limited"
assert rejection["message"] == (
"The veil is crowded. The spirits need a moment before another summoning."
)
@pytest.mark.asyncio
async def test_summon_per_ip_bucket_follows_cf_connecting_ip_not_socket_peer(
sync_client, monkeypatch
):
# Every connection in this test suite shares the same simulated socket
# peer (TestClient has no real network). Without preferring
# CF-Connecting-IP, distinct visitors behind the Cloudflare Tunnel would
# collapse into one shared per-IP bucket — this proves they don't.
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=1, window_seconds=60)
)
token_a = _login(sync_client, "cf-ip-a")
with sync_client.websocket_connect(
"/ws/session",
headers={"cookie": f"qm_session={token_a}", "cf-connecting-ip": "203.0.113.1"},
) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
_read_until(ws, "entity") # spends visitor A's per-IP slot only
token_b = _login(sync_client, "cf-ip-b")
with sync_client.websocket_connect(
"/ws/session",
headers={"cookie": f"qm_session={token_b}", "cf-connecting-ip": "203.0.113.2"},
) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
# A different visitor IP behind the same tunnel — must not be
# rejected by visitor A's already-spent bucket.
_read_until(ws, "entity")
@pytest.mark.asyncio
async def test_summon_credits_essence_trickle(sync_client, db_session, monkeypatch):
# The module-level summon_ip_limiter/summon_limiter are shared singletons
# that persist real hit counts across every test in this file (they're
# only overridden where a test explicitly monkeypatches them, as earlier
# tests above do) — fresh, generous instances here keep this test from
# depending on how many summons ran before it.
monkeypatch.setattr(app.ws, "summon_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60))
token = _login(sync_client, "trickle-earner")
user_id = uuid.UUID(
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
)
with _ws_connect(sync_client, token) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
_read_until(ws, "entity")
_read_until(ws, "utterance", kind="greeting")
for _ in range(40):
db_session.expire_all()
user = await db_session.get(User, user_id)
if user.essence == SUMMON_ESSENCE_TRICKLE:
break
await asyncio.sleep(0.05)
assert user.essence == SUMMON_ESSENCE_TRICKLE
@pytest.mark.asyncio
async def test_summon_high_rarity_item_drop_is_persisted_and_sent(
sync_client, db_session, monkeypatch
):
# Force a hit so the wiring is exercised deterministically rather than
# relying on the real roll odds (those are covered statistically in
# test_inventory.py).
forced_item = {
"item_type": "curio",
"item_key": "veil_thread",
"payload": {"trigger": "summon_mythic"},
}
monkeypatch.setattr(app.ws, "roll_item_drop", lambda trigger: forced_item)
# See test_summon_credits_essence_trickle above re: why this is reset.
monkeypatch.setattr(app.ws, "summon_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60))
class MythicSpiritService:
async def mint_profile(
self, signature, channel, anomalies, language="en", entropy=None, sky=None
):
profile = fallback_profile(signature)
profile["rarity"] = "mythic"
return profile
async def fragment(self, source, anomaly, language="en"):
return "listen"
async def wire_whisper(self, telemetry, language="en"):
return "the wire hums"
def chat_stream(self, entity, question, history, language="en"):
async def gen():
yield "hi"
return gen()
def ambient_ready(self):
return False
monkeypatch.setattr(app.ws, "spirit_service", MythicSpiritService())
token = _login(sync_client, "jackpot-seeker")
user_id = uuid.UUID(
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
)
with _ws_connect(sync_client, token) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
entity_frame = _read_until(ws, "entity")
assert entity_frame["entity"]["rarity"] == "mythic"
drop_frame = _read_until(ws, "item_drop")
assert drop_frame["item"] == forced_item
for _ in range(40):
db_session.expire_all()
items = (
await db_session.execute(
select(InventoryItem).where(InventoryItem.user_id == user_id)
)
).scalars().all()
if items:
break
await asyncio.sleep(0.05)
assert len(items) == 1
assert items[0].item_key == "veil_thread"
assert items[0].item_type == "curio"
@pytest.mark.asyncio
async def test_summon_common_rarity_does_not_roll_a_drop(sync_client, db_session, monkeypatch):
# See test_summon_credits_essence_trickle above re: why this is reset.
monkeypatch.setattr(app.ws, "summon_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60))
# Common is the overwhelmingly likely fallback rarity (55% weight) but
# not guaranteed — force it explicitly so this test can't flake.
def _common_only(signature: str) -> dict:
profile = fallback_profile(signature)
profile["rarity"] = "common"
return profile
class CommonSpiritService:
async def mint_profile(
self, signature, channel, anomalies, language="en", entropy=None, sky=None
):
return _common_only(signature)
async def fragment(self, source, anomaly, language="en"):
return "listen"
async def wire_whisper(self, telemetry, language="en"):
return "the wire hums"
def chat_stream(self, entity, question, history, language="en"):
async def gen():
yield "hi"
return gen()
def ambient_ready(self):
return False
monkeypatch.setattr(app.ws, "spirit_service", CommonSpiritService())
# If a drop were (incorrectly) rolled for a common-rarity summon, this
# would make it always hit — proving the rarity gate, not just the odds,
# is what's preventing a drop here.
monkeypatch.setattr(
app.ws, "roll_item_drop", lambda trigger: {"item_type": "x", "item_key": "y", "payload": {}}
)
token = _login(sync_client, "commoner")
user_id = uuid.UUID(
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
)
with _ws_connect(sync_client, token) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
entity_frame = _read_until(ws, "entity")
assert entity_frame["entity"]["rarity"] == "common"
greeting = _read_until(ws, "utterance", kind="greeting")
assert greeting["kind"] == "greeting"
# Give the (already-awaited, so this should be immediate) reward path a
# moment to land, then assert no item was ever persisted for this user —
# the rarity gate, not just the roll odds, is what prevents a drop here.
for _ in range(20):
db_session.expire_all()
items = (
await db_session.execute(
select(InventoryItem).where(InventoryItem.user_id == user_id)
)
).scalars().all()
await asyncio.sleep(0.02)
assert items == []
# --- scry: the camera as a channel ------------------------------------------
@pytest.mark.asyncio
async def test_scry_speaks_about_what_the_lens_shows(sync_client, monkeypatch):
"""The entity describes a real camera frame. The image must never be
persisted — only the resulting utterance, like any other spirit speech."""
seen_images = []
async def fake_scry(entity, image_b64, language="en", entropy=None):
seen_images.append(image_b64)
return "a pale column, and a red cube on the boards"
monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False)
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
)
_login(sync_client, "scryer")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
_read_until(ws, "entity")
_read_until(ws, "utterance", kind="greeting")
ws.send_json({"type": "scry", "image": "ZmFrZS1qcGVn"})
spoken = _read_until(ws, "utterance", kind="scry")
assert spoken["text"] == "a pale column, and a red cube on the boards"
assert seen_images == ["ZmFrZS1qcGVn"]
@pytest.mark.asyncio
async def test_scry_without_a_presence_is_ignored(sync_client, monkeypatch):
called = []
async def fake_scry(*a, **k):
called.append(1)
return "should not happen"
monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False)
_login(sync_client, "scryer-nobody")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
# Nothing summoned: there is nobody to look through the lens.
ws.send_json({"type": "scry", "image": "ZmFrZQ=="})
ws.send_json({"type": "ping"})
assert _read_until(ws, "pong") == {"type": "pong"}
assert called == []
@pytest.mark.asyncio
async def test_scry_rejects_an_oversized_frame_before_the_model(sync_client, monkeypatch):
called = []
async def fake_scry(*a, **k):
called.append(1)
return "nope"
monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False)
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
)
_login(sync_client, "scryer-huge")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
_read_until(ws, "entity")
ws.send_json({"type": "scry", "image": "A" * (app.ws.MAX_SCRY_B64_CHARS + 1)})
err = _read_until(ws, "error")
assert err["code"] == "scry_too_large"
# The oversized payload must never have reached the vision model.
assert called == []
@pytest.mark.asyncio
async def test_scry_survives_a_vision_failure(sync_client, monkeypatch):
"""A broken vision call must leave the séance usable, not kill the socket."""
async def exploding_scry(*a, **k):
raise RuntimeError("the model fell over")
monkeypatch.setattr(app.ws.spirit_service, "scry", exploding_scry, raising=False)
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
)
_login(sync_client, "scryer-broken")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
_read_until(ws, "entity")
ws.send_json({"type": "scry", "image": "ZmFrZQ=="})
err = _read_until(ws, "error")
assert err["code"] == "scry_failed"
# Still alive afterwards.
ws.send_json({"type": "ping"})
assert _read_until(ws, "pong") == {"type": "pong"}
@pytest.mark.asyncio
async def test_scry_is_rate_limited(sync_client, monkeypatch):
async def fake_scry(*a, **k):
return "it looks"
monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False)
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
)
monkeypatch.setattr(app.ws, "scry_limiter", RateLimiter(max_requests=1, window_seconds=60))
_login(sync_client, "scryer-limited")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
_read_until(ws, "entity")
ws.send_json({"type": "scry", "image": "ZmFrZQ=="})
_read_until(ws, "utterance", kind="scry")
ws.send_json({"type": "scry", "image": "ZmFrZQ=="})
err = _read_until(ws, "error")
assert err["code"] == "rate_limited"