homelab-codex-ws/jobs/mail-imap-sync/tests/test_prom.py
oskar f056b08574 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 15:30:57 +02:00

86 lines
3.2 KiB
Python

"""Tests for the textfile-collector renderer."""
from __future__ import annotations
from pathlib import Path
from mail_imap_sync.prom import (
MetricFamily,
Sample,
read_prev_value,
render_prom,
write_prom_atomic,
)
def _family(samples, name="kb_mail_sync_test", mtype="gauge"):
return MetricFamily(name, mtype, "a help string", samples)
class TestRender:
def test_emits_help_and_type_before_samples(self):
text = render_prom([_family([Sample(3)])])
assert text.splitlines()[:3] == [
"# HELP kb_mail_sync_test a help string",
"# TYPE kb_mail_sync_test gauge",
"kb_mail_sync_test 3",
]
def test_labels_are_sorted_and_quoted(self):
text = render_prom([_family([Sample(1, {"folder": "INBOX", "account": "gmail"})])])
assert 'kb_mail_sync_test{account="gmail",folder="INBOX"} 1' in text
def test_label_values_are_escaped(self):
text = render_prom([_family([Sample(1, {"folder": '[Gmail]/"All"\\Mail'})])])
assert 'folder="[Gmail]/\\"All\\"\\\\Mail"' in text
def test_integers_render_without_a_trailing_zero(self):
assert "kb_mail_sync_test 3\n" in render_prom([_family([Sample(3.0)])])
def test_floats_keep_their_precision(self):
assert "kb_mail_sync_test 1.5" in render_prom([_family([Sample(1.5)])])
def test_family_with_no_samples_is_omitted_entirely(self):
# "no accounts reported" and "accounts reported zero" are different facts.
assert render_prom([_family([])]) == "\n"
def test_output_ends_with_a_newline(self):
assert render_prom([_family([Sample(1)])]).endswith("\n")
class TestReadPrevValue:
def test_absent_file_returns_none(self, tmp_path: Path):
assert read_prev_value(tmp_path / "nope.prom", "kb_mail_sync_x") is None
def test_absent_metric_returns_none(self, tmp_path: Path):
path = tmp_path / "x.prom"
path.write_text("kb_mail_sync_other 5\n")
assert read_prev_value(path, "kb_mail_sync_x") is None
def test_reads_a_scientific_notation_value(self, tmp_path: Path):
path = tmp_path / "x.prom"
path.write_text("kb_mail_sync_x 1.786e9\n")
assert read_prev_value(path, "kb_mail_sync_x") == 1.786e9
def test_help_lines_are_not_mistaken_for_samples(self, tmp_path: Path):
path = tmp_path / "x.prom"
path.write_text("# HELP kb_mail_sync_x 42 things\nkb_mail_sync_x 7\n")
assert read_prev_value(path, "kb_mail_sync_x") == 7.0
class TestWriteAtomic:
def test_creates_the_directory_and_the_file(self, tmp_path: Path):
path = tmp_path / "node-exporter" / "kb-mail-sync.prom"
write_prom_atomic(path, "kb_mail_sync_x 1\n")
assert path.read_text() == "kb_mail_sync_x 1\n"
def test_leaves_no_temp_file_behind(self, tmp_path: Path):
path = tmp_path / "kb-mail-sync.prom"
write_prom_atomic(path, "kb_mail_sync_x 1\n")
assert [p.name for p in tmp_path.iterdir()] == ["kb-mail-sync.prom"]
def test_overwrites_in_place(self, tmp_path: Path):
path = tmp_path / "kb-mail-sync.prom"
write_prom_atomic(path, "kb_mail_sync_x 1\n")
write_prom_atomic(path, "kb_mail_sync_x 2\n")
assert path.read_text() == "kb_mail_sync_x 2\n"