"""Tests for app.sync.watermarks — SyncState CRUD (watermark get/set). Exercises the watermark helpers against an isolated in-memory SQLite engine (same pattern as ``tests/test_repos.py``). Covers the three guarantees the orchestrator relies on: * ``set_watermark`` then ``get_watermark`` returns the just-written row. * ``set_watermark`` is an **upsert** keyed on ``(entity_type, channel, scope_id)``: the same key twice produces exactly ONE row, with the cursor fields refreshed from the later write. * The convenience accessors (``get_last_timestamp`` / ``get_last_version``) return the stored value, or the documented default (``0`` / ``None``) when no row exists yet (first sync of a brand-new scope). """ from __future__ import annotations import sqlalchemy as sa from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base from app.db.models import SyncState from app.sync.watermarks import ( get_last_timestamp, get_last_version, get_watermark, set_watermark, ) async def _make_engine(): """Create an in-memory async SQLite engine with all tables created.""" engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) return engine # --------------------------------------------------------------------------- # set then get # --------------------------------------------------------------------------- async def test_set_then_get_returns_row_with_values(): """A fresh set creates a row; get returns it carrying the cursors.""" engine = await _make_engine() try: SessionLocal = async_sessionmaker(engine, expire_on_commit=False) async with SessionLocal() as session: await set_watermark( session, "message", channel="wechat", scope_id="wx_a:friend_b", last_version="v1", last_timestamp=1_700_000_000_000, ) await session.commit() async with SessionLocal() as session: row = await get_watermark( session, "message", channel="wechat", scope_id="wx_a:friend_b" ) assert row is not None assert row.entity_type == "message" assert row.channel == "wechat" assert row.scope_id == "wx_a:friend_b" assert row.last_version == "v1" assert row.last_timestamp == 1_700_000_000_000 assert row.last_synced_at is not None finally: await engine.dispose() # --------------------------------------------------------------------------- # upsert: same key twice -> 1 row, updated # --------------------------------------------------------------------------- async def test_set_watermark_is_upsert_one_row_updated(): """Two sets on the same (entity_type, channel, scope_id) -> exactly one row. The later write refreshes ``last_timestamp`` / ``last_version`` / ``last_synced_at``. This is the resumability guarantee the orchestrator's incremental loop leans on. """ engine = await _make_engine() try: SessionLocal = async_sessionmaker(engine, expire_on_commit=False) async with SessionLocal() as session: await set_watermark( session, "message", channel="wechat", scope_id="s1", last_timestamp=100, last_version="v1", ) await session.commit() async with SessionLocal() as session: await set_watermark( session, "message", channel="wechat", scope_id="s1", last_timestamp=200, last_version="v2", ) await session.commit() async with SessionLocal() as session: count = ( await session.execute( sa.select(sa.func.count()) .select_from(SyncState) .where(SyncState.scope_id == "s1") ) ).scalar_one() assert count == 1 row = await get_watermark( session, "message", channel="wechat", scope_id="s1" ) assert row is not None assert row.last_timestamp == 200 assert row.last_version == "v2" finally: await engine.dispose() async def test_set_watermark_partial_advance_does_not_clobber_other_cursor(): """A timestamp-only advance leaves the previously-set version intact. The orchestrator advances one cursor at a time (e.g. the message loop advances ``last_timestamp``); a partial set must NOT wipe ``last_version`` with None. """ engine = await _make_engine() try: SessionLocal = async_sessionmaker(engine, expire_on_commit=False) async with SessionLocal() as session: await set_watermark( session, "wechat_friend", channel="wechat", scope_id=None, last_version="v1", ) await session.commit() async with SessionLocal() as session: # Advance only the sync time (no new cursors supplied). await set_watermark( session, "wechat_friend", channel="wechat", scope_id=None ) await session.commit() async with SessionLocal() as session: row = await get_watermark( session, "wechat_friend", channel="wechat", scope_id=None ) assert row is not None assert row.last_version == "v1" # preserved, not clobbered to None finally: await engine.dispose() # --------------------------------------------------------------------------- # convenience accessors: defaults when row absent # --------------------------------------------------------------------------- async def test_get_last_timestamp_returns_default_when_absent(): """No row yet -> default (0) returned, not None.""" engine = await _make_engine() try: SessionLocal = async_sessionmaker(engine, expire_on_commit=False) async with SessionLocal() as session: assert ( await get_last_timestamp( session, "message", channel="wechat", scope_id="never" ) == 0 ) # Custom default is honored. assert ( await get_last_timestamp( session, "message", channel="wechat", scope_id="never", default=42, ) == 42 ) finally: await engine.dispose() async def test_get_last_version_returns_none_when_absent(): """No row yet -> None (no version cursor yet).""" engine = await _make_engine() try: SessionLocal = async_sessionmaker(engine, expire_on_commit=False) async with SessionLocal() as session: assert ( await get_last_version( session, "message", channel="wechat", scope_id="never" ) is None ) finally: await engine.dispose() async def test_get_last_timestamp_returns_stored_value_after_set(): """After a set, the accessor returns the stored timestamp (not default).""" engine = await _make_engine() try: SessionLocal = async_sessionmaker(engine, expire_on_commit=False) async with SessionLocal() as session: await set_watermark( session, "message", channel="wechat", scope_id="s2", last_timestamp=999, ) await session.commit() async with SessionLocal() as session: assert ( await get_last_timestamp( session, "message", channel="wechat", scope_id="s2" ) == 999 ) assert ( await get_last_version( session, "message", channel="wechat", scope_id="s2" ) is None ) finally: await engine.dispose()