homelab-codex-ws/packages/kb-mail/tests/test_sync_state.py

183 lines
7.5 KiB
Python
Raw Normal View History

feat(kb-mail): adapter IMAP + model stanu synca + migracja 005 Krok 7 fazy mailowej, warstwa wspoldzielona. Realizuje decyzje (a), (b), (f) reconu kb/audits/mail-sync-2026-08-06.md (zatwierdzone przez operatora 2026-08-06): jeden adapter IMAP na oba konta, stan synca jako tabela w bazie. Nowe moduly w packages/kb-mail: - imap.py — ImapAccount/ImapClient nad stdlib imaplib (zero nowych zaleznosci). Foldery otwierane READ-ONLY (EXAMINE) i pobierane przez BODY.PEEK[], zeby job nie ustawial \Seen na skrzynce operatora. Wybor folderu po atrybucie SPECIAL-USE, nigdy po nazwie — Gmail lokalizuje "[Gmail]/All Mail". search_from_uid filtruje zakres po stronie klienta, bo n:* zwraca ostatnia wiadomosc takze gdy przedzial pusty. - sync_state.py — tabela mail_sync_state + czyste funkcje: plan_folder_sync (pierwszy tick / przyrost / uniewaznienie UIDVALIDITY) i contiguous_last_uid (kursor przesuwa sie tylko po nieprzerwanym ciagu sukcesow — bledna wiadomosc jest ponawiana, nie przeskakiwana). - headers.py / message.py — parse_headers(+fallback) z gmail-header-backfill oraz message_id/parse_date/parse_attachments/eml_ref z gmail-bulk-import, przeniesione zamiast skopiowane. Klucz dedup musi pochodzic z jednej implementacji: kazdy insert przyrostowki trafia na 225 030 istniejacych id. Stare joby re-eksportuja te nazwy — ich CLI i testy bez zmian. kb_mail.db.insert_envelope zwraca teraz command tag (+ rows_affected, envelope_source) — bez tego nie da sie odroznic zwyklego duplikatu od kolizji Message-ID miedzy kontami (recon §2.4). Migracja 005_mail_sync_state.sql: addytywna, klucz (account, folder). Testy: 285 passed (111 kb-mail w tym 37 adaptera IMAP na fake serwerze i 26 planera kursora; 174 istniejace suity jobow bez zmian po ekstrakcji). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:26:51 +02:00
"""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"