homelab-codex-ws/jobs/mail-imap-sync/tests/test_config.py

140 lines
5.4 KiB
Python
Raw Normal View History

feat(mail-imap-sync): job przyrostowki + wpiecie w tor body-ingest Nowy job jobs/mail-imap-sync — jedyny wlasciwy nowy kod przyrostowki (recon kb/audits/mail-sync-2026-08-06.md §6 poz. 1). Jeden tick, per konto i folder: EXAMINE -> plan -> UID SEARCH -> FETCH BODY.PEEK[] -> save_eml -> insert_envelope(entities=[headers, attachment...]) -> UPDATE mail_sync_state. Wlasciwosci, ktore latwo zgubic po cichu: - Job NIE embeduje i NIE chunkuje (recon §3.2). Pobieranie jest sieciowe i chodzi na PIHA 24/7; chunk+embed potrzebuje Ollamy na SOLARII, wylaczanej ~16 h/dobe. Spoiwem jest kolejka wynikajaca z danych: koperta bez chunkow JEST elementem kolejki, ktora drenuje mail-body-ingest --only-unchunked. - Koperta dostaje entities[type=headers] juz przy INSERCIE. Bez tego kazdy nowy mail mialby prefiks "Temat: (brak tematu) | Od: ?" — bez bledu, tylko z gorszym retrievalem (recon §2.5 i). - Kolizja Message-ID miedzy kontami jest liczona (envelopes_conflict_other_source), nie ukryta w zwyklych duplikatach (recon §2.4). - Kursor przesuwa sie tylko po nieprzerwanym ciagu w pelni trwalych wiadomosci. Bledna wiadomosc jest ponawiana (dedup czyni to darmowym), nie przeskakiwana; trwale zatrucie widac jako niezerowy licznik bledow i stojacy kursor. - Poswiadczenia wylacznie ze srodowiska — brak flagi --password/--user (Decyzja (c)). - Tryb --measure (STATUS MESSAGES/UIDNEXT/UIDVALIDITY): pomiar, na ktorym operator oprze decyzje o historii Fastmaila (Decyzja (e), celowo nieodgadywana). - Metryki .prom per konto; last_success_timestamp przenoszony przez nieudany run. Wpiecie w istniejacy tor (recon §6 poz. 4-6): - mail_body_ingest.fetch_envelopes: --sources (domyslnie gmail,fastmail) + --only-unchunked. - fetch_existing_chunk_keys zawezone do zbioru roboczego — bez tego kazdy tick czyta wszystkie 389 012 kluczy chunkow (~26 MB, ~1,0 s) na nodzie z 2,4 GB available (recon §2.5 iv). - DEFAULT_SUMMARYLESS_SOURCES += "fastmail" (Decyzja (g)) w TYM SAMYM commicie, ktory wprowadza zrodlo: bez tego koperty fastmail zaindeksowalyby sie poprawnie i byly niewidoczne w /search, bez zadnego bledu. Testy: 80 dla nowego joba (mock IMAP na poziomie imaplib, wiec testowane jest prawdziwe parsowanie protokolu) — nowe wiadomosci, uniewaznienie UIDVALIDITY, dedup, wznowienie po przerwaniu, izolacja kont, dry-run bez sieci, metryki; + 47 mail-body-ingest/kb-retrieval. Smoke: --help, blad konfiguracji -> exit 2. Zero polaczen z zywymi kontami — pierwszy zywy sync robi operator wg runbooka. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:37:32 +02:00
"""Tests for env-only account configuration.
Two properties matter more than the parsing details: a malformed or missing setting is a
startup failure rather than a quiet half-configuration, and the approved folder scope
(Decyzja (e)) is the default so a working config is short.
"""
from __future__ import annotations
from datetime import date
import pytest
from mail_imap_sync.config import (
ConfigError,
account_from_env,
accounts_from_env,
archive_root_from_env,
)
BASE = {
"MAIL_ACCOUNTS": "gmail,fastmail",
"MAIL_GMAIL_USER": "u@gmail.com",
"MAIL_GMAIL_APP_PASSWORD": "pw1",
"MAIL_FASTMAIL_USER": "u@fastmail.com",
"MAIL_FASTMAIL_APP_PASSWORD": "pw2",
}
def env(**overrides) -> dict:
merged = dict(BASE)
merged.update(overrides)
return {k: v for k, v in merged.items() if v is not None}
class TestDefaults:
def test_gmail_defaults_to_the_special_use_all_folder(self):
account = account_from_env("gmail", env())
assert account.special_use == ("\\All",)
assert account.folders == ()
def test_fastmail_defaults_to_inbox_archive_sent(self):
account = account_from_env("fastmail", env())
assert account.folders == ("INBOX", "Archive", "Sent")
assert account.special_use == ()
def test_spam_trash_and_drafts_are_not_in_any_default_scope(self):
for name in ("gmail", "fastmail"):
scope = account_from_env(name, env()).folders
assert not {"Spam", "Trash", "Drafts"} & set(scope)
def test_known_hosts_are_defaults_not_requirements(self):
assert account_from_env("gmail", env()).host == "imap.gmail.com"
assert account_from_env("gmail", env(MAIL_GMAIL_HOST="mail.example.com")).host \
== "mail.example.com"
def test_first_tick_defaults_to_new_only(self):
assert account_from_env("fastmail", env()).initial_mode == "new-only"
class TestOverrides:
def test_folders_override_replaces_the_default_list(self):
account = account_from_env("fastmail", env(MAIL_FASTMAIL_FOLDERS="INBOX,Projects"))
assert account.folders == ("INBOX", "Projects")
def test_empty_override_means_none_not_fall_back_to_default(self):
account = account_from_env("gmail", env(MAIL_GMAIL_SPECIAL_USE="",
MAIL_GMAIL_FOLDERS="INBOX"))
assert account.special_use == ()
assert account.folders == ("INBOX",)
def test_since_mode_parses_the_date(self):
account = account_from_env("gmail", env(MAIL_GMAIL_INITIAL_MODE="since",
MAIL_GMAIL_INITIAL_SINCE="2026-06-15"))
assert account.initial_since == date(2026, 6, 15)
def test_port_override(self):
assert account_from_env("gmail", env(MAIL_GMAIL_PORT="1993")).port == 1993
def test_archive_root_override(self):
assert str(archive_root_from_env({"MAIL_ARCHIVE_ROOT": "/srv/mail"})) == "/srv/mail"
class TestFailsLoudly:
def test_missing_accounts_list(self):
with pytest.raises(ConfigError, match="MAIL_ACCOUNTS"):
accounts_from_env({})
def test_missing_password(self):
with pytest.raises(ConfigError, match="APP_PASSWORD"):
account_from_env("gmail", env(MAIL_GMAIL_APP_PASSWORD=None))
def test_missing_user(self):
with pytest.raises(ConfigError, match="USER"):
account_from_env("gmail", env(MAIL_GMAIL_USER=None))
def test_unknown_account_without_a_host(self):
with pytest.raises(ConfigError, match="HOST"):
account_from_env("mailbox-org", {"MAIL_MAILBOX_ORG_USER": "u",
"MAIL_MAILBOX_ORG_APP_PASSWORD": "p"})
def test_unknown_account_with_a_host_but_no_scope(self):
with pytest.raises(ConfigError, match="no scope"):
account_from_env("mailbox-org", {"MAIL_MAILBOX_ORG_USER": "u",
"MAIL_MAILBOX_ORG_APP_PASSWORD": "p",
"MAIL_MAILBOX_ORG_HOST": "imap.mailbox.org"})
def test_bad_port(self):
with pytest.raises(ConfigError, match="PORT"):
account_from_env("gmail", env(MAIL_GMAIL_PORT="993x"))
def test_bad_initial_mode(self):
with pytest.raises(ConfigError, match="INITIAL_MODE"):
account_from_env("gmail", env(MAIL_GMAIL_INITIAL_MODE="yesterday"))
def test_since_mode_without_a_date(self):
with pytest.raises(ConfigError, match="initial_since"):
account_from_env("gmail", env(MAIL_GMAIL_INITIAL_MODE="since"))
def test_bad_since_date(self):
with pytest.raises(ConfigError, match="not a date"):
account_from_env("gmail", env(MAIL_GMAIL_INITIAL_MODE="since",
MAIL_GMAIL_INITIAL_SINCE="15/06/2026"))
def test_duplicate_account_name(self):
with pytest.raises(ConfigError, match="twice"):
accounts_from_env(env(MAIL_ACCOUNTS="gmail,gmail"))
class TestAccountsFromEnv:
def test_returns_accounts_in_the_listed_order(self):
accounts = accounts_from_env(env(MAIL_ACCOUNTS="fastmail,gmail"))
assert [a.name for a in accounts] == ["fastmail", "gmail"]
def test_a_single_account_is_a_valid_config(self):
accounts = accounts_from_env(env(MAIL_ACCOUNTS="gmail"))
assert [a.name for a in accounts] == ["gmail"]
def test_account_name_is_the_envelope_source(self):
assert accounts_from_env(env())[0].name == "gmail"