"""Unit tests for the sync cursor: the plan a tick makes, and how far the cursor may move. These two pure functions carry the whole correctness argument of the poller — a wrong branch here loses mail with no error anywhere — so they are tested directly, without a DB. The asyncpg helpers are tested against an in-memory fake connection, the same style as `jobs/gmail-header-backfill/tests`. """ from __future__ import annotations from datetime import date, datetime, timezone import pytest from kb_mail.imap import FolderStatus, ImapAccount from kb_mail.sync_state import ( SEARCH_ALL, SEARCH_FROM_UID, SEARCH_NONE, SEARCH_SINCE, FolderSyncState, contiguous_last_uid, fetch_all_state, get_folder_state, plan_folder_sync, upsert_folder_state, ) def _account(**kwargs) -> ImapAccount: base = dict(name="gmail", host="h", user="u", password="p", special_use=("\\All",)) base.update(kwargs) return ImapAccount(**base) def _status(uidvalidity=42, uidnext=101) -> FolderStatus: return FolderStatus(name="[Gmail]/All Mail", uidvalidity=uidvalidity, uidnext=uidnext) def _state(uidvalidity=42, last_uid=90) -> FolderSyncState: return FolderSyncState(account="gmail", folder="[Gmail]/All Mail", uidvalidity=uidvalidity, last_uid=last_uid) class TestPlanFirstTick: def test_new_only_fetches_nothing_and_records_the_high_water_mark(self): plan = plan_folder_sync(_account(initial_mode="new-only"), _status(uidnext=101), None) assert plan.search == SEARCH_NONE assert plan.baseline_uid == 100 assert plan.mode == "initial-new-only" def test_new_only_on_an_empty_mailbox_records_zero_not_minus_one(self): plan = plan_folder_sync(_account(initial_mode="new-only"), _status(uidnext=1), None) assert plan.baseline_uid == 0 def test_since_mode_carries_the_configured_date(self): account = _account(initial_mode="since", initial_since=date(2026, 6, 15)) plan = plan_folder_sync(account, _status(), None) assert plan.search == SEARCH_SINCE assert plan.since == date(2026, 6, 15) def test_full_mode_sweeps_everything(self): plan = plan_folder_sync(_account(initial_mode="full"), _status(), None) assert plan.search == SEARCH_ALL assert plan.mode == "initial-full" def test_first_tick_is_never_flagged_as_a_uidvalidity_reset(self): plan = plan_folder_sync(_account(initial_mode="full"), _status(), None) assert plan.uidvalidity_reset is False class TestPlanIncremental: def test_resumes_from_the_uid_after_the_cursor(self): plan = plan_folder_sync(_account(), _status(uidvalidity=42), _state(last_uid=90)) assert plan.search == SEARCH_FROM_UID assert plan.start_uid == 91 assert plan.mode == "incremental" def test_baseline_is_the_stored_cursor_so_an_empty_tick_changes_nothing(self): plan = plan_folder_sync(_account(), _status(), _state(last_uid=90)) assert plan.baseline_uid == 90 def test_initial_mode_is_ignored_once_a_cursor_exists(self): # Otherwise flipping initial_mode to 'full' in .env would re-sweep every folder # on the next tick instead of only affecting folders with no state. account = _account(initial_mode="since", initial_since=date(2026, 1, 1)) plan = plan_folder_sync(account, _status(), _state()) assert plan.search == SEARCH_FROM_UID class TestPlanUidvalidityReset: def test_changed_uidvalidity_forces_a_full_sweep(self): plan = plan_folder_sync(_account(), _status(uidvalidity=99), _state(uidvalidity=42)) assert plan.search == SEARCH_ALL assert plan.uidvalidity_reset is True assert plan.mode == "uidvalidity-reset" def test_stored_cursor_is_discarded_not_reused_as_a_floor(self): # The stored last_uid means nothing under a new UIDVALIDITY; keeping it as a floor # would skip every message whose new UID happens to fall below it. plan = plan_folder_sync(_account(), _status(uidvalidity=99, uidnext=51), _state(uidvalidity=42, last_uid=9000)) assert plan.start_uid is None assert plan.baseline_uid == 50 class TestContiguousLastUid: def test_all_succeeded_advances_to_the_last_uid(self): assert contiguous_last_uid(90, [91, 92, 93], {91, 92, 93}) == 93 def test_stops_before_the_first_failure(self): assert contiguous_last_uid(90, [91, 92, 93], {91, 93}) == 91 def test_failure_on_the_first_uid_leaves_the_cursor_where_it_was(self): assert contiguous_last_uid(90, [91, 92], {92}) == 90 def test_nothing_attempted_returns_the_floor(self): assert contiguous_last_uid(90, [], set()) == 90 def test_sparse_uid_sets_advance_over_gaps(self): # A SINCE search returns non-contiguous UIDs; skipped-by-design UIDs are not failures. assert contiguous_last_uid(0, [95, 97, 100], {95, 97, 100}) == 100 def test_unsorted_input_is_ordered_before_walking(self): assert contiguous_last_uid(0, [93, 91, 92], {91, 92}) == 92 def test_full_sweep_floor_of_zero_with_an_early_failure_keeps_the_cursor_at_zero(self): # This is why baseline_uid (uidnext-1) must NOT be used as a floor for partial # results: it would step straight over everything that failed. assert contiguous_last_uid(0, [1, 2, 3], {2, 3}) == 0 class _FakeConn: def __init__(self, row=None, rows=None): self.row = row self.rows = rows or [] self.executed: list[tuple] = [] self.queries: list[tuple] = [] async def fetchrow(self, query, *params): self.queries.append((query, params)) return self.row async def fetch(self, query, *params): self.queries.append((query, params)) return self.rows async def execute(self, query, *params): self.executed.append((query, params)) return "INSERT 0 1" class TestStateHelpers: async def test_get_folder_state_returns_none_when_absent(self): assert await get_folder_state(_FakeConn(), "gmail", "INBOX") is None async def test_get_folder_state_decodes_a_row(self): ts = datetime(2026, 8, 6, tzinfo=timezone.utc) conn = _FakeConn(row={"account": "gmail", "folder": "INBOX", "uidvalidity": 42, "last_uid": 90, "last_sync_ts": ts}) state = await get_folder_state(conn, "gmail", "INBOX") assert (state.uidvalidity, state.last_uid, state.last_sync_ts) == (42, 90, ts) async def test_upsert_is_an_on_conflict_update(self): conn = _FakeConn() await upsert_folder_state(conn, "gmail", "INBOX", 42, 91) query, params = conn.executed[-1] assert "ON CONFLICT (account, folder) DO UPDATE" in query assert params == ("gmail", "INBOX", 42, 91) async def test_upsert_stamps_last_sync_ts_server_side(self): # last_sync_ts answers "is this folder being polled at all" — it must advance on # empty ticks too, so it is set by the statement rather than passed in. conn = _FakeConn() await upsert_folder_state(conn, "gmail", "INBOX", 42, 91) assert "now()" in conn.executed[-1][0] async def test_fetch_all_state_is_ordered(self): conn = _FakeConn(rows=[{"account": "fastmail", "folder": "INBOX", "uidvalidity": 1, "last_uid": 2, "last_sync_ts": None}]) states = await fetch_all_state(conn) assert "ORDER BY account, folder" in conn.queries[-1][0] assert states[0].account == "fastmail"