Files
2026-07-13 13:12:33 +08:00

223 lines
7.4 KiB
Python

"""Tests for the epoch production path.
Verifies that ``SessionManager.append_message`` passes ``expected_epoch``
through to storage; that a concurrent reset + write pair is atomic so
the write either fully succeeds (pre-reset) or fully fails (post-reset)
with no partial rows; and that ``_emit_to_subscribers`` reads the
in-process epoch cache after warm-up rather than hitting the DB on
every event.
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from opensquilla.session.manager import SessionManager
from opensquilla.session.models import SessionNode, TranscriptEntry
from opensquilla.session.storage import SessionStorage, StaleEpochError
# ── fixtures ─────────────────────────────────────────────────────────────────
@pytest_asyncio.fixture
async def storage():
s = SessionStorage(":memory:")
await s.connect()
yield s
await s.close()
async def _make_session(storage: SessionStorage, key: str = "agent:main:prod") -> SessionNode:
node = SessionNode(session_key=key, session_id="sid-prod")
await storage.upsert_session(node)
return node
# ── append_message passes expected_epoch ────────────────────────────────────
@pytest.mark.asyncio
async def test_manager_passes_expected_epoch(storage):
"""SessionManager.append_message must pass expected_epoch= to storage.
Uses real storage so we can verify the epoch guard fires on a stale write.
"""
node = await _make_session(storage)
key = node.session_key
mgr = SessionManager(storage)
# epoch=0 at this point — a normal write must succeed.
entry = await mgr.append_message(key, "user", "hello")
assert entry is not None
# Now simulate a reset: epoch bumps to 1.
await storage.increment_epoch(key)
# The manager reads the node (epoch=0 snapshot) then tries to write — but
# the storage epoch is now 1. The atomic guard must reject it.
# We can reproduce this by monkey-patching get_session to return a stale node.
stale_node = await storage.get_session(key)
stale_node.epoch = 0 # artificially stale
original_get = storage.get_session
async def _stale_get(k):
return stale_node
storage.get_session = _stale_get
try:
with pytest.raises(StaleEpochError):
await mgr.append_message(key, "assistant", "stale response")
finally:
storage.get_session = original_get
# ── append_message passes expected_epoch (mock variant) ──────────────────────
@pytest.mark.asyncio
async def test_manager_passes_expected_epoch_mock():
"""Verify expected_epoch kwarg is forwarded via mock, not just via error path."""
mock_storage = MagicMock()
mock_storage.get_session = AsyncMock(
return_value=SessionNode(
session_key="agent:main:mock",
session_id="sid-mock",
epoch=7,
)
)
mock_storage.upsert_session = AsyncMock()
mock_storage.append_transcript_entry = AsyncMock()
mgr = SessionManager(mock_storage)
await mgr.append_message("agent:main:mock", "user", "hi")
mock_storage.append_transcript_entry.assert_awaited_once()
_, kwargs = mock_storage.append_transcript_entry.call_args
assert "expected_epoch" in kwargs, (
"append_transcript_entry must be called with expected_epoch= keyword"
)
assert kwargs["expected_epoch"] == 7, (
f"expected_epoch should be 7 (from node.epoch), got {kwargs['expected_epoch']}"
)
# ── concurrent reset + write is atomic ──────────────────────────────────────
@pytest.mark.asyncio
async def test_concurrent_reset_during_write_atomic(storage):
"""Reset concurrent with write: result is atomic — write fully succeeds or fully fails.
Spawns 3 coroutines all holding epoch=0; fires a reset (epoch→1); all 3
writes must raise StaleEpochError and leave zero rows in transcript.
"""
node = await _make_session(storage)
key = node.session_key
captured_epoch = await storage.get_epoch(key) # 0
# Reset fires while writes are in-flight.
await storage.increment_epoch(key)
assert await storage.get_epoch(key) == 1
def _entry(label: str) -> TranscriptEntry:
return TranscriptEntry(
session_id=node.session_id,
session_key=key,
role="assistant",
content=f"concurrent {label}",
)
async def _write(label: str) -> str:
try:
await storage.append_transcript_entry(
_entry(label), expected_epoch=captured_epoch
)
return "ok"
except StaleEpochError:
return "stale"
results = await asyncio.gather(_write("A"), _write("B"), _write("C"))
# All must be rejected — no partial commit.
assert all(r == "stale" for r in results), (
f"All concurrent stale writes must be rejected; got {results}"
)
entries = await storage.get_transcript(node.session_id)
assert entries == [], f"No rows must be written after stale epoch; found {entries}"
# ── _emit_to_subscribers uses cache after warm-up ──────────────────────────
@pytest.mark.asyncio
async def test_emit_no_db_query_per_event(storage):
"""_emit_to_subscribers must not call storage.get_epoch on every event.
After the first cache-miss DB hit, subsequent emits for the same session
read the in-process cache. 100 emits → < 5 DB calls.
"""
from opensquilla.gateway.rpc_sessions import _emit_to_subscribers
node = await _make_session(storage)
key = node.session_key
# Track get_epoch call count.
call_count = 0
original_get_epoch = storage.get_epoch
async def _counting_get_epoch(session_key: str) -> int:
nonlocal call_count
call_count += 1
return await original_get_epoch(session_key)
storage.get_epoch = _counting_get_epoch
class FakeConn:
async def send_event(self, event_name: str, payload: dict) -> None:
pass
fake_conn = FakeConn()
class FakeRegistry:
def get(self, conn_id: str):
return fake_conn
class FakeSubMgr:
def get_message_subscribers(self, session_key: str):
return {"conn-1"}
def get_session_subscribers(self):
return set()
# Use a real SessionManager so _epoch_cache is available.
session_manager = SessionManager(storage)
fake_ctx = SimpleNamespace(
subscription_manager=FakeSubMgr(),
session_manager=session_manager,
)
import opensquilla.gateway.websocket as _ws_module
original_get_registry = _ws_module.get_registry
_ws_module.get_registry = lambda: FakeRegistry()
try:
for _ in range(100):
await _emit_to_subscribers(
fake_ctx, key, "session.event.text_delta", {"text": "x"}
)
finally:
_ws_module.get_registry = original_get_registry
assert call_count < 5, (
f"storage.get_epoch called {call_count} times for 100 emits; "
f"expected < 5 (cache should be used after first miss)"
)