139 lines
5.2 KiB
Python
139 lines
5.2 KiB
Python
"""Reconciliation.
|
|||
|
|
|
||
|
|
These exist because the estate spent seven months treating "the provider
|
||
|
|
accepted it" as "the person got it". Every test here is about telling those two
|
||
|
|
apart.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.providers import Response
|
||
|
|
from app.reconcile import Message, describe, fetch, reconcile, window_start
|
||
|
|
|
||
|
|
|
||
|
|
def ledger(*rows) -> Response:
|
||
|
|
return Response(status=200, body=json.dumps({"messages": list(rows)}), headers={})
|
||
|
|
|
||
|
|
|
||
|
|
def row(sid="SM1", to="+15550001111", status="delivered", error=None):
|
||
|
|
return {"sid": sid, "to": to, "status": status, "error_code": error,
|
||
|
|
"date_sent": "Tue, 19 Aug 2026 00:00:00 +0000"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_accepted_message_the_carrier_refused_is_a_failure_not_a_success():
|
||
|
|
"""The whole point. Twilio said 201 at send time for every one of these."""
|
||
|
|
report = reconcile([
|
||
|
|
Message("SM1", "+15550001111", "delivered", None, None),
|
||
|
|
Message("SM2", "+15550002222", "undelivered", "30032", None),
|
||
|
|
Message("SM3", "+15550003333", "failed", "30006", None),
|
||
|
|
])
|
||
|
|
assert report.delivered == 1
|
||
|
|
assert [m.sid for m in report.failed] == ["SM2", "SM3"]
|
||
|
|
assert report.ok is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_sent_is_not_delivered():
|
||
|
|
"""Twilio's `sent` means it left Twilio, not that a handset received it —
|
||
|
|
counting it as success is the same mistake one layer down."""
|
||
|
|
report = reconcile([Message("SM1", "+15550001111", "sent", None, None)])
|
||
|
|
assert report.delivered == 0
|
||
|
|
assert report.in_flight == 1
|
||
|
|
assert report.ok is True # not yet a failure, but not a delivery either
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_clean_window_is_ok():
|
||
|
|
report = reconcile([Message("SM1", "+1555", "delivered", None, None)])
|
||
|
|
assert report.ok and not report.failed
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_unrecognised_status_is_surfaced_rather_than_assumed_good():
|
||
|
|
report = reconcile([Message("SM1", "+1555", "carrier_shrugged", None, None)])
|
||
|
|
assert report.unknown == ["carrier_shrugged"]
|
||
|
|
assert report.delivered == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_report_groups_by_reason_and_never_prints_a_full_number():
|
||
|
|
report = reconcile([
|
||
|
|
Message("SM1", "+17066762576", "undelivered", "30032", None),
|
||
|
|
Message("SM2", "+17066762576", "undelivered", "30032", None),
|
||
|
|
Message("SM3", "+15551230000", "failed", "30006", None),
|
||
|
|
])
|
||
|
|
text = describe(report)
|
||
|
|
assert "2x error 30032" in text.replace(" ", " ")
|
||
|
|
assert "***2576" in text
|
||
|
|
assert "+17066762576" not in text, "a report that leaks full numbers cannot be pasted anywhere"
|
||
|
|
assert "unverified toll-free" in text
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_ledger_query_reads_every_sender_not_just_ours():
|
||
|
|
"""Reconciling against our own record would have missed the failures that
|
||
|
|
started this, because a different service sent them."""
|
||
|
|
captured = {}
|
||
|
|
|
||
|
|
def fake_get(url, *, headers, timeout):
|
||
|
|
captured["url"] = url
|
||
|
|
return ledger(row(sid="SMx", status="undelivered", error="30032"))
|
||
|
|
|
||
|
|
found = fetch(account_sid="AC123", auth_token="secret",
|
||
|
|
since=datetime(2026, 8, 12, tzinfo=timezone.utc), get=fake_get)
|
||
|
|
|
||
|
|
assert "Messages.json" in captured["url"]
|
||
|
|
assert "2026-08-12" in captured["url"]
|
||
|
|
assert "secret" not in captured["url"], "credentials belong in the header, not the query"
|
||
|
|
assert [m.status for m in found] == ["undelivered"]
|
||
|
|
assert found[0].error_code == "30032"
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_refused_ledger_query_is_loud():
|
||
|
|
def refuse(url, *, headers, timeout):
|
||
|
|
return Response(status=401, body="unauthorized", headers={})
|
||
|
|
|
||
|
|
with pytest.raises(RuntimeError):
|
||
|
|
fetch(account_sid="AC1", auth_token="bad", since=window_start(24), get=refuse)
|
||
|
|
|
||
|
|
|
||
|
|
def test_window_start_looks_backwards():
|
||
|
|
assert window_start(24) < datetime.now(timezone.utc)
|
||
|
|||
|
|
|
||
|
|
# --------------------------------------------------------------- email
|
||
|
|
|
||
|
|
from app.reconcile import EmailReport, describe_email, email_bounces # noqa: E402
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_key_that_cannot_read_bounces_says_so_instead_of_reporting_clean():
|
||
|
|
"""The estate's key is send-only. Skipping the check quietly is how a
|
||
|
|
channel nobody can see becomes a channel nobody checks."""
|
||
|
|
def forbidden(url, *, headers, timeout):
|
||
|
|
return Response(status=403, body='{"errors":[{"message":"access forbidden"}]}', headers={})
|
||
|
|
|
||
|
|
report = email_bounces(api_key="SG.x", since=window_start(24), get=forbidden)
|
||
|
|
|
||
|
|
assert report.checked is False
|
||
|
|
assert report.bounces == []
|
||
|
|
assert "suppression.read" in report.reason
|
||
|
|
assert "NOT VERIFIED" in describe_email(report)
|
||
|
|
|
||
|
|
|
||
|
|
def test_bounces_are_reported_without_publishing_addresses():
|
||
|
|
def ledger_of_bounces(url, *, headers, timeout):
|
||
|
|
return Response(status=200, headers={}, body=json.dumps([
|
||
|
|
{"email": "[email protected]", "reason": "550 5.1.1 user unknown"}]))
|
||
|
|
|
||
|
|
report = email_bounces(api_key="SG.x", since=window_start(24), get=ledger_of_bounces)
|
||
|
|
text = describe_email(report)
|
||
|
|
|
||
|
|
assert report.checked and len(report.bounces) == 1
|
||
|
|
assert "***@example.test" in text
|
||
|
|
assert "[email protected]" not in text
|
||
|
|
|
||
|
|
|
||
|
|
def test_no_key_is_reported_as_unverified_not_as_healthy():
|
||
|
|
report = email_bounces(api_key="", since=window_start(24))
|
||
|
|
assert report.checked is False and "no SendGrid key" in report.reason
|