245 lines
8.3 KiB
Python
245 lines
8.3 KiB
Python
|
|
"""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()
|