"""DbEventStore 集成测试:sqlite 建表 → 写入 → 检索,验证持久化与实时推送。""" from __future__ import annotations from collections.abc import AsyncIterator import pytest from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base from app.modules.runtime.events import Event from app.services.event_store import DbEventStore @pytest.fixture async def store() -> AsyncIterator[DbEventStore]: """内存 sqlite + 全表建表(FK 不强制,故无需 characters/runs 先验行)。""" engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) factory = async_sessionmaker(engine, expire_on_commit=False) yield DbEventStore(factory) await engine.dispose() async def test_append_assigns_monotonic_sequence(store: DbEventStore) -> None: await store.append(Event(type="tool.started", source="tool", run_id="run_1")) await store.append(Event(type="tool.completed", source="tool", run_id="run_1")) events = await store.list_by_run("run_1") assert [e.type for e in events] == ["tool.started", "tool.completed"] assert [e.sequence for e in events] == [1, 2] async def test_payload_and_evidence_ids_roundtrip(store: DbEventStore) -> None: await store.append( Event( type="memory.candidate.created", source="memory", run_id="run_1", character_id="char_1", payload={"summary": "测试", "score": 0.8}, evidence_ids=["evidence_a", "evidence_b"], sensitivity="medium", ) ) events = await store.list_by_run("run_1") assert events[0].payload == {"summary": "测试", "score": 0.8} assert events[0].evidence_ids == ["evidence_a", "evidence_b"] assert events[0].character_id == "char_1" assert events[0].sensitivity == "medium" async def test_run_without_run_id_persists(store: DbEventStore) -> None: """运行外事件(run_id=None)可持久化,不撞 NOT NULL / 唯一约束。""" await store.append(Event(type="drive.need.detected", source="drive")) # 用 run_id 查不到,但写入不应报错;这里仅验证无异常 + run 内事件仍正常 await store.append(Event(type="tool.started", source="tool", run_id="run_x")) events = await store.list_by_run("run_x") assert len(events) == 1 async def test_subscribe_receives_realtime(store: DbEventStore) -> None: queue = store.subscribe("run_1") await store.append(Event(type="tool.started", source="tool", run_id="run_1")) await store.append(Event(type="tool.completed", source="tool", run_id="run_1")) first = queue.get_nowait() second = queue.get_nowait() assert first.type == "tool.started" assert second.type == "tool.completed" store.unsubscribe("run_1", queue) async def test_persistence_across_sessions(tmp_path) -> None: """重启后(新 engine/session)事件仍在——验证真正落盘。""" db_path = tmp_path / "events.sqlite3" url = f"sqlite+aiosqlite:///{db_path}" engine = create_async_engine(url) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) factory = async_sessionmaker(engine, expire_on_commit=False) store1 = DbEventStore(factory) await store1.append( Event(type="run.started", source="run", run_id="run_persist", character_id="char_1") ) await engine.dispose() # 新 engine 同一文件 engine2 = create_async_engine(url) factory2 = async_sessionmaker(engine2, expire_on_commit=False) store2 = DbEventStore(factory2) events = await store2.list_by_run("run_persist") await engine2.dispose() assert len(events) == 1 assert events[0].type == "run.started" assert events[0].character_id == "char_1" async def test_subscribe_by_pattern_glob_match(store: DbEventStore) -> None: """DbEventStore 的 subscribe_by_pattern 与 InMemoryEventStore 语义一致。""" queue = store.subscribe_by_pattern("time.*") await store.append(Event(type="time.reunion", source="time_sense", run_id="run_1")) await store.append(Event(type="run.started", source="run", run_id="run_1")) received = queue.get_nowait() assert received.type == "time.reunion" assert queue.empty() store.unsubscribe_by_pattern("time.*", queue)