"""Tests for navi.todo_store — the dedicated session_todos table."""
import json
from datetime import datetime, timezone
from navi.todo_store import KvTodoStore, PgTodoStore, plan_timestamp
from tests.conftest_factory import FakeConnection, FakePool, FakeRecord
def _pg(conn: FakeConnection | None = None) -> tuple[PgTodoStore, FakeConnection]:
conn = conn or FakeConnection()
store = PgTodoStore(FakePool(conn))
# Skip the DDL/migration round trip: those two execute() calls would eat
# the results a test queued for the query under test.
store._ready = True
return store, conn
class TestPgTodoStore:
async def test_load_returns_rows(self):
store, conn = _pg()
conn.enqueue(FakeRecord(tasks='[{"text": "a", "status": "done"}]'))
assert await store.load("u1", "s1") == [{"text": "a", "status": "done"}]
op, sql, args = conn.calls[-1]
assert op == "fetchrow"
assert "FROM session_todos" in sql
assert args == ("u1", "s1")
async def test_load_accepts_already_decoded_json(self):
store, conn = _pg()
conn.enqueue(FakeRecord(tasks=[{"text": "a", "status": "pending"}]))
assert await store.load(None, "s1") == [{"text": "a", "status": "pending"}]
assert conn.calls[-1][2] == ("", "s1")
async def test_load_missing_row_is_none(self):
store, conn = _pg()
assert await store.load("u1", "s1") is None
async def test_load_garbage_row_is_none(self):
store, conn = _pg()
conn.enqueue(FakeRecord(tasks="{not json"))
assert await store.load("u1", "s1") is None
async def test_load_meta(self):
store, conn = _pg()
now = datetime.now(timezone.utc)
conn.enqueue(FakeRecord(revision=3, updated_at=now))
assert await store.load_meta("u1", "s1") == {"revision": 3, "updated_at": now}
assert "SELECT revision, updated_at" in conn.calls[-1][1]
async def test_save_writes_json_and_bumps_revision(self):
store, conn = _pg()
await store.save("u1", "s1", [{"text": "a", "status": "done"}])
op, sql, args = conn.calls[-1]
assert op == "execute"
assert "INSERT INTO session_todos" in sql
assert "revision = session_todos.revision + 1" in sql
assert args[0] == "u1" and args[1] == "s1"
assert json.loads(args[2]) == [{"text": "a", "status": "done"}]
async def test_clear_deletes_row(self):
store, conn = _pg()
await store.clear("u1", "s1")
op, sql, args = conn.calls[-1]
assert op == "execute"
assert sql.startswith("DELETE FROM session_todos")
assert args == ("u1", "s1")
async def test_ddl_and_migration_run_once(self):
conn = FakeConnection()
store = PgTodoStore(FakePool(conn))
await store.load("u1", "s1")
first = [c[1] for c in conn.calls if c[0] == "execute"]
assert any("CREATE TABLE IF NOT EXISTS session_todos" in q for q in first)
assert any("FROM session_store" in q for q in first)
# The legacy row is removed after the copy, so clearing a plan later is
# not undone by the next process start.
assert any("DELETE FROM session_store" in q for q in first)
n = len(conn.calls)
await store.load("u1", "s1")
# Only the SELECT was issued the second time.
assert len(conn.calls) == n + 1
assert conn.calls[-1][1].startswith("SELECT tasks")
async def test_migration_failure_does_not_disable_store(self):
import asyncpg
conn = FakeConnection()
# execute() for DDL returns the queued exception first, which is
# consumed by _DDL; queue the failure for the migration call instead
# by making the first call succeed implicitly.
conn.enqueue("OK")
conn.enqueue(asyncpg.UndefinedTableError("session_store"))
store = PgTodoStore(FakePool(conn))
await store._ensure_ready()
assert store._ready is True
class TestKvTodoStore:
class _Kv:
def __init__(self):
self.data = {}
async def get(self, uid, sid, scope, key):
return self.data.get((uid, sid, scope, key))
async def set(self, uid, sid, scope, key, value):
self.data[(uid, sid, scope, key)] = value
async def clear_scope(self, uid, sid, scope):
for k in [k for k in self.data if k[:3] == (uid, sid, scope)]:
del self.data[k]
async def test_round_trip(self):
kv = self._Kv()
store = KvTodoStore(kv)
await store.save("u1", "s1", [{"text": "a"}])
assert await store.load("u1", "s1") == [{"text": "a"}]
await store.clear("u1", "s1")
assert await store.load("u1", "s1") is None
async def test_no_meta(self):
assert await KvTodoStore(self._Kv()).load_meta("u1", "s1") is None
async def test_invalid_json_is_none(self):
kv = self._Kv()
await kv.set("u1", "s1", "todo", "tasks", "{broken")
assert await KvTodoStore(kv).load("u1", "s1") is None
def test_plan_timestamp():
now = datetime.now(timezone.utc)
assert plan_timestamp({"updated_at": now}) == now
assert plan_timestamp({"updated_at": "nope"}) is None
assert plan_timestamp({}) is None
assert plan_timestamp(None) is None