mail-address-semantics/tests/test_modes_master.py

153 lines
6.3 KiB
Python
Raw Normal View History

"""Email as master (CONTRACT.md 3.1) against the callback stub (MAS-WP-0010 T04/T05)."""
import pytest
from mas_modes.contract import message, post
from mas_modes.harness import Harness, ROOT
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
ROLES = {"ticket": {"role": "master", "fields": ("phone", "time", "scenario"), "timeout": 3600}}
DETAILS = "Phone: +49 30 1234567\nTime: tomorrow 10:00"
@pytest.fixture
def h():
harness = Harness(STATUTE, ROLES, ticket_ids=(f"gs-{n}" for n in range(1, 50)))
yield harness
harness.close()
def start(h, text=DETAILS, local="ticket!callback", **kw):
d = h.mail(local, text, **kw)
assert d.status == 201, d.response
return d.response["ticket_id"], d.response["handoff_id"]
def bodies(h):
return [m["body"] for m in h.outbox()]
def test_completed(h):
tid, hid = start(h, DETAILS + "\nScenario: completed")
[req] = h.callback.received
assert req["type"] == "handoff.request" and req["details"]["phone"] == "+49 30 1234567"
assert req["case"] == {"address": f"ticket={tid}@modes.example", "key": f"ticket={tid}"}
h.callback.run()
assert h.statuses(hid) == ["scheduled", "running", "completed"]
assert h.thread(tid) == ["callback: requested", "callback: scheduled at=2026-10-20T10:00:00Z",
"callback: running", "callback: completed"]
assert "has taken place" in bodies(h)[-1]
assert h.outbox()[-1]["mail_from"] == f"ticket={tid}@modes.example"
def test_negotiation_over_email(h):
tid, hid = start(h, "Please call me.\nScenario: negotiate")
h.callback.run()
assert h.statuses(hid) == ["negotiating"]
question = bodies(h)[-1]
assert "Phone: ..." in question and "Time: ..." in question
d = h.mail(f"ticket={tid}", DETAILS, subject="Re: Help needed")
assert d.response["status"] == "correlated" and d.response["handoff_id"] == hid
assert h.callback.received[-1]["type"] == "handoff.update"
h.callback.run()
assert h.statuses(hid) == ["negotiating", "scheduled", "running", "completed"]
def test_details_in_first_mail_skip_negotiation(h):
tid, hid = start(h, DETAILS + "\nScenario: negotiate")
h.callback.run()
assert h.statuses(hid) == ["scheduled", "running", "completed"]
@pytest.mark.parametrize("scenario, statuses, text", [
("no-answer-retry", ["scheduled", "attempted", "scheduled", "running", "completed"], "has taken place"),
("no-answer-fail", ["scheduled", "attempted", "scheduled", "attempted", "failed"], "did not succeed"),
("call-failed", ["scheduled", "running", "failed"], "line dropped"),
("expired", ["scheduled", "expired"], "expired"),
("unavailable", ["refused"], "mode-unavailable"),
("declined", ["refused"], "declined"),
("out-of-hours", ["refused"], "out-of-hours"),
])
def test_outcomes_resume_by_email(h, scenario, statuses, text):
tid, hid = start(h, f"{DETAILS}\nScenario: {scenario}")
h.callback.run()
assert h.statuses(hid) == statuses
assert text in bodies(h)[-1] and "We continue by email" in bodies(h)[-1]
def test_email_owns_the_timeout(h):
tid, hid = start(h, DETAILS + "\nScenario: silent")
assert h.callback.run() == []
assert h.advance(3599) == []
assert h.advance(2) == [hid]
assert h.callback.received[-1]["type"] == "handoff.cancel"
assert "could not arrange" in bodies(h)[-1]
assert h.thread(tid)[-1] == "callback: timed-out"
assert h.advance(10_000) == [] # only once
def test_mode_not_enabled_by_statute(h):
d = h.mail("ticket!chat", "hi")
assert d.status == 201 and h.callback.received == []
assert h.thread(d.response["ticket_id"]) == ["chat: refused reason=mode-not-enabled"]
assert len(h.outbox()) == 1 # only the creation reply, which explains it
def test_mode_endpoint_down():
roles = {"ticket": {**ROLES["ticket"],
"modes": {"callback": {"url": "http://127.0.0.1:9/handoffs", "peer": "callback-1"}}}}
h = Harness(STATUTE, roles)
try:
d = h.mail("ticket!callback", DETAILS)
tid = d.response["ticket_id"]
assert h.thread(tid)[-1].startswith("callback: refused reason=mode-unavailable")
assert "mode-unavailable" in [m["body"] for m in h.outbox()][-1]
finally:
h.close()
def test_handoff_on_existing_ticket(h):
d = h.mail("ticket", "no mode")
tid = d.response["ticket_id"]
d = h.mail(f"ticket={tid}!callback", DETAILS)
assert d.response["status"] == "correlated" and "handoff_id" in d.response
assert h.callback.received[-1]["type"] == "handoff.request"
def test_unauthenticated_sender_gets_no_mail(h):
tid, hid = start(h, "Scenario: negotiate", dmarc="fail")
h.callback.run()
assert h.statuses(hid) == ["negotiating"]
assert h.outbox() == [] # no creation reply, no question: principle 10
def as_mode(h):
"""Post as the callback stub does: signed, at the handoff's status URL."""
url = h.callback.received[0]["status_url"]
return lambda msg: post(url, msg, ("callback-1", h.keys["callback-1"]))
def test_contract_guards(h):
tid, hid = start(h, DETAILS + "\nScenario: completed")
case = h.callback.run()[0]["case"]
send = as_mode(h)
stale = message("handoff.status", case, handoff_id=hid, seq=1, status="running")
assert send(stale) == (200, {"ok": True, "ignored": "stale"})
code, body = send(message("handoff.status", case, handoff_id=hid, seq=99, status="timed-out"))
assert code == 409 and body["error"] == "invalid-transition"
code, body = send(message("handoff.status", case, handoff_id=hid, seq=99, status="negotiating",
needs=["phone"]))
assert code == 409 # completed is terminal
code, body = send(message("handoff.status", case, handoff_id="nope", seq=1, status="running"))
assert code == 404
code, body = send({"type": "handoff.status"})
assert code == 400 and body["error"] == "schema"
def test_repeated_message_id_is_processed_once(h):
tid, hid = start(h, DETAILS + "\nScenario: silent")
case = h.callback.received[0]["case"]
send = as_mode(h)
msg = message("handoff.status", case, handoff_id=hid, seq=1, status="scheduled", at="x")
assert send(msg) == send(msg) == (200, {"ok": True})
assert h.thread(tid).count("callback: scheduled at=x") == 1