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>
149 lines
5 KiB
Python
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"))
|