Newer
Older
navi-1 / tests / unit / store / test_todo_store.py
"""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