mail-address-semantics/tests/test_outbound.py
tegwick 8de8fddf37 MAS-WP-0012 finished: one outbound path and one send cap
mas_modes.mailer.Mailer is the only outbound path (own SQLite + lock),
shared by the ticket service and both EmailMode roles. Over the cap mail
is held and reported (send-cap-reached, held); held mail is purged after
email-held-duration-max (7d) and not stored beyond emails-held-storage-max
(100MB). The ticket service's legacy outbox is imported once so the live
count carries over (checked on a copy of the live DB: 4 sent). Entrypoint
passes the cap and both limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 12:44:16 +02:00

114 lines
4.8 KiB
Python

"""One outbound path and one send cap (MAS-WP-0012)."""
import sqlite3
import pytest
from mas_modes.harness import ROOT, Harness, reference_ticket_service
from mas_modes.mailer import Mailer, parse_duration, parse_size
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
ENTRYPOINT = ROOT / "deploy/railiance/entrypoint.sh"
OK = {"from": "c@example.net", "authentication": {"dmarc": "pass"}, "subject": "s", "message_id": "<m@x>"}
def mailer(**kw):
m = Mailer(smtp=("sink", 0), **kw)
m.sent = []
m._send = lambda *mail: m.sent.append(mail)
return m
def test_both_roles_share_one_cap():
h = Harness(STATUTE, {"ticket": {"role": "master"}, "case": {"role": "slave"}}, max_sent=3)
try:
for n in range(2):
assert h.mail("ticket", f"ticket {n}").status == 201 # 2 creation replies
h.mail("case", "case intake")
[(_, body)] = h.master.pump()
code, resp = h.master.send(body["case"], body["message_ref"], "on it")
assert (code, resp["sent"]) == (200, True) # third send: the cap
code, resp = h.master.send(body["case"], body["message_ref"], "again")
assert code == 409 and resp["reason"] == "send-cap-reached" and resp["held"] is True
assert h.mail("ticket", "ticket 3").status == 201 # ticket created, reply held
assert len(h.sent) == 3 # exactly N, across both roles
held = [m for m in h.mailer.outbox() if m["held"]]
assert [m["body"] for m in held][0] == "again" and len(held) == 2
finally:
h.close()
def test_held_mail_is_purged_after_max_age():
clock = [0.0]
m = mailer(max_sent=0, held_max_age=parse_duration("7d"), clock=lambda: clock[0])
assert m.send("t1", "a@d", OK, "s", "held") == ("send-cap-reached", True)
clock[0] += 7 * 86400 - 1
assert m.purge_held() == 0
clock[0] += 2
assert m.purge_held() == 1 and m.outbox() == []
m.send("t1", "a@d", OK, "s", "new") # purging also runs on every send
clock[0] += 8 * 86400
m.send("t1", "a@d", OK, "s", "newer")
assert [r["body"] for r in m.outbox()] == ["newer"]
def test_held_storage_limit():
m = mailer(max_sent=0, held_max_bytes=parse_size("1KB"))
assert m.send("t", "a@d", OK, "s", "x" * 600)[1] is True
assert m.send("t", "a@d", OK, "s", "y" * 600) == ("send-cap-reached", False) # would exceed 1 KB
assert len(m.outbox()) == 1 and m.held_bytes() < 1024
def test_sent_mail_is_never_purged():
clock = [0.0]
m = mailer(max_sent=1, clock=lambda: clock[0])
assert m.reply("t", "a@d", OK, "s", "sent") is None
clock[0] += 365 * 86400
m.purge_held()
assert [(r["body"], r["sent"]) for r in m.outbox()] == [("sent", 1)]
assert m.cap_reached()
def test_legacy_outbox_counts_once(tmp_path):
db = tmp_path / "tickets.sqlite3"
con = sqlite3.connect(db)
con.execute("CREATE TABLE outbox (seq INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id TEXT,"
" mail_from TEXT, mail_to TEXT, subject TEXT, body TEXT, in_reply_to TEXT,"
" sent INTEGER DEFAULT 0)")
con.executemany("INSERT INTO outbox (ticket_id, mail_from, mail_to, subject, body, in_reply_to, sent)"
" VALUES (?,?,?,?,?,?,?)", [("gs-1", "t@d", "c@x", "s", "b", None, 1)] * 2
+ [("gs-2", "t@d", "c@x", "s", "b", None, 0)])
con.commit()
con.close()
out = tmp_path / "tickets-outbox.sqlite3"
TicketService = reference_ticket_service()
svc = TicketService(str(db), mailer=mailer(path=str(out), max_sent=3))
assert svc.mailer.sent_count() == 2 and len(svc.outbox()) == 3
svc = TicketService(str(db), mailer=mailer(path=str(out), max_sent=3)) # restart: no re-import
assert len(svc.outbox()) == 3
assert svc.mailer.reply("gs-1", "t@d", OK, "s", "third") is None
assert svc.send_cap_reached() # 2 legacy + 1 new = the cap of 3
@pytest.mark.parametrize("text, seconds", [("7d", 604800), ("12h", 43200), ("30m", 1800), ("45", 45)])
def test_parse_duration(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text, size", [("100MB", 100 * 1024 ** 2), ("512KB", 512 * 1024), ("1gb", 1024 ** 3),
("2048", 2048)])
def test_parse_size(text, size):
assert parse_size(text) == size
def test_bad_config_values():
with pytest.raises(ValueError):
parse_duration("seven days")
with pytest.raises(ValueError):
parse_size("100 MiB")
def test_entrypoint_passes_one_cap_and_hold_limits():
text = ENTRYPOINT.read_text()
assert "--max-sent ${MAS_MAX_SENT:-300}" in text
assert "--email-held-duration-max ${MAS_EMAIL_HELD_DURATION_MAX:-7d}" in text
assert "--emails-held-storage-max ${MAS_EMAILS_HELD_STORAGE_MAX:-100MB}" in text