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>
127 lines
4.6 KiB
Python
127 lines
4.6 KiB
Python
"""Sanity tests for SQL migration files — no DB or network required."""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent.parent.parent
|
|
INIT_DIR = REPO_ROOT / "services" / "kb-postgres" / "init"
|
|
|
|
|
|
def test_migration_001_exists():
|
|
assert (INIT_DIR / "001_envelope.sql").exists(), \
|
|
f"Expected {INIT_DIR / '001_envelope.sql'} to exist"
|
|
|
|
|
|
def test_migration_001_enables_vector_extension():
|
|
sql = (INIT_DIR / "001_envelope.sql").read_text()
|
|
assert "CREATE EXTENSION" in sql
|
|
assert "vector" in sql.lower()
|
|
|
|
|
|
def test_migration_001_creates_envelope_table():
|
|
sql = (INIT_DIR / "001_envelope.sql").read_text()
|
|
assert "CREATE TABLE" in sql
|
|
assert "envelope" in sql.lower()
|
|
|
|
|
|
def test_migration_001_has_all_frozen_columns():
|
|
sql = (INIT_DIR / "001_envelope.sql").read_text()
|
|
frozen_columns = ("id", "source", "ts", "geo", "raw_ref", "entities")
|
|
for col in frozen_columns:
|
|
assert col in sql, f"Frozen column '{col}' missing from 001_envelope.sql"
|
|
|
|
|
|
def test_migration_001_geo_is_nullable():
|
|
sql = (INIT_DIR / "001_envelope.sql").read_text()
|
|
# geo must NOT have NOT NULL constraint
|
|
lines = [l for l in sql.splitlines() if "geo" in l.lower()]
|
|
assert lines, "No 'geo' line found in migration SQL"
|
|
for line in lines:
|
|
assert "NOT NULL" not in line.upper(), \
|
|
f"geo must be nullable but found: {line.strip()}"
|
|
|
|
|
|
def test_migration_001_entities_has_default():
|
|
sql = (INIT_DIR / "001_envelope.sql").read_text()
|
|
lines = [l for l in sql.splitlines() if "entities" in l.lower()]
|
|
assert any("DEFAULT" in l.upper() for l in lines), \
|
|
"entities column must have a DEFAULT '[]'"
|
|
|
|
|
|
def test_migration_002_exists():
|
|
assert (INIT_DIR / "002_chunks.sql").exists(), \
|
|
f"Expected {INIT_DIR / '002_chunks.sql'} to exist"
|
|
|
|
|
|
def test_migration_002_does_not_touch_envelope():
|
|
sql = (INIT_DIR / "002_chunks.sql").read_text()
|
|
assert "ALTER TABLE envelope" not in sql
|
|
assert "DROP TABLE envelope" not in sql.upper()
|
|
|
|
|
|
def test_migration_002_creates_document_chunk_table():
|
|
sql = (INIT_DIR / "002_chunks.sql").read_text()
|
|
assert "CREATE TABLE IF NOT EXISTS document_chunk" in sql
|
|
|
|
|
|
def test_migration_002_references_envelope_with_cascade():
|
|
sql = (INIT_DIR / "002_chunks.sql").read_text()
|
|
assert "REFERENCES envelope(id) ON DELETE CASCADE" in sql
|
|
|
|
|
|
def test_migration_002_embedding_dimension_matches_bge_m3():
|
|
sql = (INIT_DIR / "002_chunks.sql").read_text()
|
|
# bge-m3 dense embedding dimension is 1024 (kb/phases/kb-m5-faza2.md §1.4)
|
|
assert "VECTOR(1024)" in sql.upper()
|
|
|
|
|
|
def test_migration_002_has_unique_envelope_chunk_index():
|
|
sql = (INIT_DIR / "002_chunks.sql").read_text()
|
|
assert "UNIQUE (envelope_id, chunk_index)" in sql
|
|
|
|
|
|
def test_migration_002_uses_idempotent_ddl():
|
|
sql = (INIT_DIR / "002_chunks.sql").read_text()
|
|
assert "CREATE TABLE IF NOT EXISTS" in sql
|
|
for line in sql.splitlines():
|
|
if line.strip().upper().startswith("CREATE INDEX"):
|
|
assert "IF NOT EXISTS" in line.upper(), \
|
|
f"CREATE INDEX must be idempotent: {line.strip()}"
|
|
|
|
|
|
def test_migration_005_exists():
|
|
assert (INIT_DIR / "005_mail_sync_state.sql").exists(), \
|
|
f"Expected {INIT_DIR / '005_mail_sync_state.sql'} to exist"
|
|
|
|
|
|
def test_migration_005_creates_mail_sync_state_idempotently():
|
|
sql = (INIT_DIR / "005_mail_sync_state.sql").read_text()
|
|
assert "CREATE TABLE IF NOT EXISTS mail_sync_state" in sql
|
|
|
|
|
|
def test_migration_005_has_all_cursor_columns():
|
|
sql = (INIT_DIR / "005_mail_sync_state.sql").read_text()
|
|
for col in ("account", "folder", "uidvalidity", "last_uid", "last_sync_ts"):
|
|
assert col in sql, f"Column '{col}' missing from 005_mail_sync_state.sql"
|
|
|
|
|
|
def test_migration_005_is_keyed_per_account_and_folder():
|
|
# Folder scope is per account (recon Decyzja (e)): gmail syncs one \All folder,
|
|
# fastmail syncs three. A single-column key could not express that.
|
|
sql = (INIT_DIR / "005_mail_sync_state.sql").read_text()
|
|
assert "PRIMARY KEY (account, folder)" in sql
|
|
|
|
|
|
def test_migration_005_uidvalidity_and_last_uid_are_not_nullable():
|
|
sql = (INIT_DIR / "005_mail_sync_state.sql").read_text()
|
|
for col in ("uidvalidity", "last_uid"):
|
|
line = next(l for l in sql.splitlines() if l.strip().startswith(col))
|
|
assert "NOT NULL" in line.upper(), f"{col} must be NOT NULL but found: {line.strip()}"
|
|
|
|
|
|
def test_migration_005_is_additive():
|
|
sql = (INIT_DIR / "005_mail_sync_state.sql").read_text().upper()
|
|
for table in ("ENVELOPE", "DOCUMENT_CHUNK", "DOCUMENT_SUMMARY"):
|
|
assert f"ALTER TABLE {table}" not in sql
|
|
assert f"DROP TABLE {table}" not in sql
|