homelab-codex-ws/jobs/gmail-bulk-import/tests/test_importer.py
oskar 6dc1d325f1 feat(kb-mail): etap 2 — jednorazowy bulk importer Gmail (mbox → archiwum)
One-shot job `jobs/gmail-bulk-import/` wczytuje plik .mbox z Google Takeout
i importuje każdą wiadomość do archiwum .eml + opcjonalnie do koperty w DB.
Idempotentny (FileExistsError → skip; ON CONFLICT DO NOTHING w DB).
15 testów jednostkowych (bez DB, bez zewnętrznych serwisów) — wszystkie zielone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:07:11 +02:00

149 lines
5 KiB
Python

"""Unit tests for Gmail bulk importer — no DB, no external services required."""
from __future__ import annotations
import mailbox
from datetime import datetime, timezone
from pathlib import Path
import pytest
from gmail_bulk_import.importer import _message_id, _parse_date, run_import
def _make_msg(headers: dict) -> mailbox.mboxMessage:
msg = mailbox.mboxMessage()
for k, v in headers.items():
msg[k] = v
return msg
def _make_mbox(path: Path, msgs: list[mailbox.mboxMessage]) -> None:
mbox = mailbox.mbox(str(path), create=True)
for msg in msgs:
mbox.add(msg)
mbox.flush()
mbox.close()
def _msg_with_id(mid: str = "<test-unique@example.com>") -> mailbox.mboxMessage:
return _make_msg({
"From": "alice@example.com",
"Message-ID": mid,
"Date": "Tue, 10 Jun 2025 12:00:00 +0000",
"Subject": "Hello",
})
class TestMessageId:
def test_uses_message_id_header(self):
msg = _make_msg({"Message-ID": "<foo-123@example.com>"})
assert _message_id(msg) == "foo-123@example.com"
def test_strips_angle_brackets(self):
msg = _make_msg({"Message-ID": " <bar@baz.com> "})
assert _message_id(msg) == "bar@baz.com"
def test_fallback_to_sha256_when_missing(self):
msg = _make_msg({"From": "a@b.com", "Subject": "no id"})
result = _message_id(msg)
assert result.startswith("sha256-")
assert len(result) == len("sha256-") + 32
def test_sha256_is_stable(self):
msg = _make_msg({"From": "a@b.com", "Subject": "stable"})
assert _message_id(msg) == _message_id(msg)
class TestParseDate:
def test_parses_rfc2822(self):
msg = _make_msg({"Date": "Tue, 10 Jun 2025 12:00:00 +0000"})
ts = _parse_date(msg)
assert ts == datetime(2025, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
def test_result_is_utc_aware(self):
msg = _make_msg({"Date": "Tue, 10 Jun 2025 12:00:00 +0000"})
assert _parse_date(msg).tzinfo is not None
def test_falls_back_to_epoch_on_bad_date(self):
msg = _make_msg({"Date": "not-a-date"})
assert _parse_date(msg) == datetime(1970, 1, 1, tzinfo=timezone.utc)
def test_falls_back_to_epoch_when_missing(self):
msg = _make_msg({"From": "a@b.com"})
assert _parse_date(msg) == datetime(1970, 1, 1, tzinfo=timezone.utc)
def test_converts_offset_to_utc(self):
msg = _make_msg({"Date": "Tue, 10 Jun 2025 14:00:00 +0200"})
ts = _parse_date(msg)
assert ts == datetime(2025, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
class TestRunImport:
async def test_dry_run_counts_without_writing(self, tmp_path):
mbox_path = tmp_path / "mail.mbox"
archive = tmp_path / "archive"
_make_mbox(mbox_path, [_msg_with_id("<a@e.com>"), _msg_with_id("<b@e.com>")])
stats = await run_import(mbox_path, archive, dry_run=True)
assert stats["processed"] == 2
assert stats["imported"] == 2
assert not archive.exists()
async def test_imports_eml_to_archive(self, tmp_path):
mbox_path = tmp_path / "mail.mbox"
archive = tmp_path / "archive"
_make_mbox(mbox_path, [_msg_with_id()])
stats = await run_import(mbox_path, archive)
assert stats["imported"] == 1
assert stats["skipped"] == 0
assert stats["errors"] == 0
eml_files = list(archive.rglob("*.eml"))
assert len(eml_files) == 1
async def test_eml_stored_under_gmail_source(self, tmp_path):
mbox_path = tmp_path / "mail.mbox"
archive = tmp_path / "archive"
_make_mbox(mbox_path, [_msg_with_id()])
await run_import(mbox_path, archive)
eml_files = list(archive.rglob("*.eml"))
assert eml_files[0].parts[len(archive.parts)] == "gmail"
async def test_idempotent_second_run_skips(self, tmp_path):
mbox_path = tmp_path / "mail.mbox"
archive = tmp_path / "archive"
_make_mbox(mbox_path, [_msg_with_id()])
await run_import(mbox_path, archive)
stats2 = await run_import(mbox_path, archive)
assert stats2["imported"] == 0
assert stats2["skipped"] == 1
assert stats2["errors"] == 0
async def test_two_distinct_messages_both_imported(self, tmp_path):
mbox_path = tmp_path / "mail.mbox"
archive = tmp_path / "archive"
_make_mbox(mbox_path, [_msg_with_id("<msg1@e.com>"), _msg_with_id("<msg2@e.com>")])
stats = await run_import(mbox_path, archive)
assert stats["imported"] == 2
assert stats["errors"] == 0
assert len(list(archive.rglob("*.eml"))) == 2
async def test_archive_only_mode_no_dsn(self, tmp_path):
"""Without a DSN, job runs archive-only (no DB connection attempted)."""
mbox_path = tmp_path / "mail.mbox"
archive = tmp_path / "archive"
_make_mbox(mbox_path, [_msg_with_id()])
stats = await run_import(mbox_path, archive, dsn=None)
assert stats["imported"] == 1
assert list(archive.rglob("*.eml"))