95 lines
4 KiB
Python
95 lines
4 KiB
Python
|
|
"""Tests for the message-level derivations extracted out of gmail-bulk-import.
|
||
|
|
|
||
|
|
The derivations themselves have been exercised by that job's suite (and by 225 030 real
|
||
|
|
messages) since June; what is new here is that they are now shared, and that `eml_ref` takes
|
||
|
|
`source` as a parameter instead of hardcoding `gmail/`. These tests pin the properties the
|
||
|
|
poller depends on: ids identical to the ones already in the table, and archive paths that
|
||
|
|
agree with `save_eml` for BOTH sources.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import email
|
||
|
|
import email.policy
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from kb_mail.archive import save_eml
|
||
|
|
from kb_mail.message import EPOCH, eml_ref, message_id, parse_attachments, parse_date
|
||
|
|
|
||
|
|
|
||
|
|
def _msg(raw: bytes):
|
||
|
|
return email.message_from_bytes(raw, policy=email.policy.compat32)
|
||
|
|
|
||
|
|
|
||
|
|
class TestMessageId:
|
||
|
|
def test_strips_angle_brackets(self):
|
||
|
|
assert message_id(_msg(b"Message-ID: <abc@example.com>\r\n\r\nbody")) == "abc@example.com"
|
||
|
|
|
||
|
|
def test_missing_header_falls_back_to_a_content_hash(self):
|
||
|
|
mid = message_id(_msg(b"Subject: no id\r\n\r\nbody"))
|
||
|
|
assert mid.startswith("sha256-")
|
||
|
|
assert len(mid) == len("sha256-") + 32
|
||
|
|
|
||
|
|
def test_content_hash_is_stable_for_identical_bytes(self):
|
||
|
|
raw = b"Subject: no id\r\n\r\nbody"
|
||
|
|
assert message_id(_msg(raw)) == message_id(_msg(raw))
|
||
|
|
|
||
|
|
def test_eight_bit_header_bytes_do_not_raise(self):
|
||
|
|
# compat32 hands back an email.header.Header here, not a str — an unguarded
|
||
|
|
# .strip() on it killed a full bulk-import run once.
|
||
|
|
mid = message_id(_msg(b"Message-ID: <\xc4\x85bc@example.com>\r\n\r\nbody"))
|
||
|
|
assert mid
|
||
|
|
|
||
|
|
|
||
|
|
class TestParseDate:
|
||
|
|
def test_parses_to_utc(self):
|
||
|
|
ts = parse_date(_msg(b"Date: Mon, 15 Jun 2026 14:00:00 +0200\r\n\r\nbody"))
|
||
|
|
assert ts == datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc)
|
||
|
|
|
||
|
|
def test_missing_date_is_epoch(self):
|
||
|
|
assert parse_date(_msg(b"Subject: x\r\n\r\nbody")) == EPOCH
|
||
|
|
|
||
|
|
def test_unparseable_date_is_epoch(self):
|
||
|
|
assert parse_date(_msg(b"Date: not a date at all\r\n\r\nbody")) == EPOCH
|
||
|
|
|
||
|
|
def test_naive_date_is_assumed_utc(self):
|
||
|
|
ts = parse_date(_msg(b"Date: Mon, 15 Jun 2026 14:00:00 -0000\r\n\r\nbody"))
|
||
|
|
assert ts.tzinfo is timezone.utc
|
||
|
|
|
||
|
|
|
||
|
|
class TestParseAttachments:
|
||
|
|
def test_inline_text_is_not_an_attachment(self):
|
||
|
|
assert parse_attachments(_msg(b"Content-Type: text/plain\r\n\r\nhello")) == []
|
||
|
|
|
||
|
|
def test_attachment_manifest_carries_sha256_and_size(self):
|
||
|
|
raw = (
|
||
|
|
b'Content-Type: multipart/mixed; boundary="b"\r\n\r\n--b\r\n'
|
||
|
|
b"Content-Type: text/plain\r\n\r\nhello\r\n--b\r\n"
|
||
|
|
b"Content-Type: application/pdf\r\n"
|
||
|
|
b'Content-Disposition: attachment; filename="doc.pdf"\r\n'
|
||
|
|
b"Content-Transfer-Encoding: base64\r\n\r\nJVBERg==\r\n--b--\r\n"
|
||
|
|
)
|
||
|
|
[att] = parse_attachments(_msg(raw))
|
||
|
|
assert att["type"] == "attachment"
|
||
|
|
assert att["filename"] == "doc.pdf"
|
||
|
|
assert att["size"] == 4 # decoded payload, not the base64 text
|
||
|
|
assert len(att["sha256"]) == 64
|
||
|
|
|
||
|
|
|
||
|
|
class TestEmlRef:
|
||
|
|
def test_layout_is_source_year_month(self):
|
||
|
|
ts = datetime(2026, 6, 15, tzinfo=timezone.utc)
|
||
|
|
assert eml_ref("abc@example.com", "fastmail", ts) == "fastmail/2026/06/abc@example.com.eml"
|
||
|
|
|
||
|
|
def test_unsafe_characters_are_translated(self):
|
||
|
|
ts = datetime(2026, 6, 15, tzinfo=timezone.utc)
|
||
|
|
assert eml_ref("<a/b:c>", "gmail", ts) == "gmail/2026/06/a_b_c.eml"
|
||
|
|
|
||
|
|
async def test_agrees_with_save_eml_for_a_new_source(self, tmp_path: Path):
|
||
|
|
# The two must not drift: on FileExistsError the caller fills raw_ref from eml_ref
|
||
|
|
# for a file save_eml wrote on an earlier run.
|
||
|
|
ts = datetime(2026, 6, 15, tzinfo=timezone.utc)
|
||
|
|
written = await save_eml(tmp_path, "id/with:chars", "fastmail", ts, b"raw")
|
||
|
|
assert written == eml_ref("id/with:chars", "fastmail", ts)
|
||
|
|
assert (tmp_path / written).read_bytes() == b"raw"
|