homelab-codex-ws/jobs/mail-imap-sync/tests/test_config.py
oskar b640eb8b55 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

140 lines
5.4 KiB
Python

"""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"