Parent Call coordinator

Sits between the classrooms, Companion and ProPresenter, and owns the
truth about a parent call: validation, ids, the queue, expiry,
duplicates, mode, approval, delivery, and every status a classroom is
told. Companion is an operator surface and holds no state.

Built after querying the church's own ProPresenter rather than working
from the assumed contract, which turned out to be wrong in two ways: the
message is called Parent Caller, not Parent Call, and its tokens are
Name and Room rather than child and room.

The finding that shaped the design is recorded in
docs/propresenter-api.md. A trigger carrying a token name the message
does not have still returns 204, and still puts the message on screen
showing the previous call's values. So a 204 is not evidence on its own:
the adapter reads the live message and refuses to trigger unless the
configured tokens exist in it. Without that, a typo in .env would call
the wrong child's parents and report success.

Nothing claims more than it knows. A successful API call is reported as
sent to sanctuary, never as displayed.

The state machine is pure and synchronous, with time passed in and
effects returned, so the awkward cases are testable properly: two rooms
calling at once, an approval for the wrong id, an approval arriving
after the request went stale, and a clear timer firing after the next
call has gone up. The queue is in memory and dies with the process,
because a parent call surfacing ten minutes late would be worse than one
that was lost.

81 tests, then the whole thing end to end on Control: a request through
the real broker and the real coordinator to the real ProPresenter, with
the correct child and room on the sanctuary screen, the coordinator's
own clear timer taking it down again, mode off sending nothing, and a
duplicate id not sending twice.
This commit is contained in:
Ekklesia
2026-09-06 15:15:11 -05:00
commit f6c3bac732
22 changed files with 2814 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+370
View File
@@ -0,0 +1,370 @@
"""The whole coordinator, against a real broker and a mock ProPresenter.
Uses Control's own Mosquitto, on a throwaway topic root so nothing here
can touch the real classroom/parent-call topics.
"""
import json
import os
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
import paho.mqtt.client as mqtt
import pytest
from parent_call import contract
from parent_call.config import Settings
from parent_call.coordinator import Coordinator
from parent_call.propresenter import ProPresenter
BROKER = os.environ.get("TEST_MQTT_HOST", "127.0.0.1")
PORT = int(os.environ.get("TEST_MQTT_PORT", "1883"))
UUID = "7D044452-B5A1-49F5-AC9C-1FD53A9A7353"
def broker_available():
import socket
try:
with socket.create_connection((BROKER, PORT), timeout=2):
return True
except OSError:
return False
pytestmark = pytest.mark.skipif(
not broker_available(), reason=f"no broker at {BROKER}:{PORT}"
)
class FakePP(BaseHTTPRequestHandler):
triggers = []
clears = []
status = 204
tokens = ["Name", "Room"]
def log_message(self, *args):
pass
def _send(self, code, body=None):
self.send_response(code)
if body is None:
self.send_header("Content-Length", "0")
self.end_headers()
return
raw = json.dumps(body).encode()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
if self.path == "/v1/messages":
return self._send(200, [{
"id": {"name": "Parent Caller", "uuid": UUID},
"tokens": [{"name": t, "text": {"text": ""}} for t in FakePP.tokens],
}])
if self.path.endswith("/clear"):
FakePP.clears.append(time.time())
return self._send(204)
return self._send(404)
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
FakePP.triggers.append(json.loads(self.rfile.read(length) or b"[]"))
return self._send(FakePP.status)
class Listener:
"""A test classroom/operator: publishes and collects what comes back."""
def __init__(self, root):
self.root = root
self.seen = []
self.lock = threading.Lock()
self.client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=f"test-{uuid.uuid4().hex[:8]}",
clean_session=True,
)
self.client.on_message = self._on_message
self.client.connect(BROKER, PORT, 30)
self.client.subscribe(f"{root}/#", qos=1)
self.client.loop_start()
def _on_message(self, client, userdata, message):
with self.lock:
self.seen.append((message.topic, message.payload.decode(), message.retain))
def publish(self, topic, payload, retain=False):
self.client.publish(topic, payload, qos=1, retain=retain).wait_for_publish(5)
def statuses(self, request_id):
prefix = f"{self.root}/status/{request_id}"
with self.lock:
return [json.loads(p)["status"] for t, p, _ in self.seen if t == prefix]
def wait_for(self, predicate, timeout=8):
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(0.05)
return False
def latest(self, topic):
with self.lock:
for t, p, _ in reversed(self.seen):
if t == topic:
return json.loads(p) if p.startswith("{") else p
return None
def stop(self):
self.client.loop_stop()
self.client.disconnect()
@pytest.fixture
def rig():
root = f"test-parent-call/{uuid.uuid4().hex[:8]}"
FakePP.triggers = []
FakePP.clears = []
FakePP.status = 204
FakePP.tokens = ["Name", "Room"]
httpd = HTTPServer(("127.0.0.1", 0), FakePP)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{httpd.server_port}"
settings = Settings(
mqtt_host=BROKER, mqtt_port=PORT,
mqtt_client_id=f"coord-{uuid.uuid4().hex[:8]}",
topic_root=root,
propresenter_base_url=base,
propresenter_clear_after=0,
request_ttl=180,
default_mode="approval",
room_names={"nursery": "Nursery"},
)
listener = Listener(root)
coordinator = Coordinator(
settings,
ProPresenter(base, "Parent Caller", "Name", "Room", timeout=3),
)
coordinator.start()
assert listener.wait_for(lambda: coordinator.connected), "coordinator never connected"
yield coordinator, listener, settings, contract.Topics(root)
coordinator.stop()
listener.stop()
httpd.shutdown()
def call(topics, listener, request_id="nursery-1", room="nursery", child="Emma", age=0):
listener.publish(topics.request, json.dumps({
"id": request_id, "room": room, "child": child,
"created_at": time.time() - age,
}))
def test_auto_mode_reaches_propresenter(rig):
coordinator, listener, settings, topics = rig
listener.publish(topics.mode_set, "auto")
assert listener.wait_for(lambda: coordinator.state.mode == "auto")
call(topics, listener)
assert listener.wait_for(lambda: FakePP.triggers), "nothing reached ProPresenter"
assert FakePP.triggers[0] == [
{"name": "Name", "text": {"text": "Emma"}},
{"name": "Room", "text": {"text": "Nursery"}},
]
assert listener.wait_for(
lambda: contract.SENT_TO_SANCTUARY in listener.statuses("nursery-1")
)
def test_approval_mode_waits_then_sends(rig):
coordinator, listener, settings, topics = rig
call(topics, listener)
assert listener.wait_for(
lambda: contract.WAITING_APPROVAL in listener.statuses("nursery-1")
)
assert FakePP.triggers == []
pending = listener.latest(topics.pending)
assert pending["id"] == "nursery-1" and pending["waiting"] == 1
listener.publish(topics.approve, json.dumps({"id": "nursery-1"}))
assert listener.wait_for(lambda: FakePP.triggers)
assert listener.wait_for(
lambda: contract.SENT_TO_SANCTUARY in listener.statuses("nursery-1")
)
assert listener.wait_for(lambda: listener.latest(topics.pending)["waiting"] == 0)
def test_rejection(rig):
coordinator, listener, settings, topics = rig
call(topics, listener)
assert listener.wait_for(lambda: coordinator.state.queue)
listener.publish(topics.reject, json.dumps({"id": "nursery-1"}))
assert listener.wait_for(
lambda: contract.REJECTED in listener.statuses("nursery-1")
)
assert FakePP.triggers == []
def test_off_mode_is_honest(rig):
coordinator, listener, settings, topics = rig
listener.publish(topics.mode_set, "off")
assert listener.wait_for(lambda: coordinator.state.mode == "off")
call(topics, listener)
assert listener.wait_for(
lambda: contract.UNAVAILABLE in listener.statuses("nursery-1")
)
assert FakePP.triggers == []
assert coordinator.state.queue == []
def test_two_rooms_and_the_operator_picks_the_second(rig):
coordinator, listener, settings, topics = rig
call(topics, listener, "nursery-a", "nursery", "Emma")
call(topics, listener, "k2-b", "k2", "Noah")
assert listener.wait_for(lambda: len(coordinator.state.queue) == 2)
listener.publish(topics.approve, json.dumps({"id": "k2-b"}))
assert listener.wait_for(lambda: FakePP.triggers)
assert FakePP.triggers[0][0]["text"]["text"] == "Noah"
assert [r.id for r in coordinator.state.queue] == ["nursery-a"]
def test_propresenter_failure_never_reports_success(rig):
coordinator, listener, settings, topics = rig
FakePP.status = 500
listener.publish(topics.mode_set, "auto")
assert listener.wait_for(lambda: coordinator.state.mode == "auto")
call(topics, listener)
assert listener.wait_for(lambda: contract.ERROR in listener.statuses("nursery-1"))
assert contract.SENT_TO_SANCTUARY not in listener.statuses("nursery-1")
def test_misconfigured_tokens_never_report_success(rig):
"""The failure that would call the wrong child's parents."""
coordinator, listener, settings, topics = rig
FakePP.tokens = ["child", "room"]
listener.publish(topics.mode_set, "auto")
assert listener.wait_for(lambda: coordinator.state.mode == "auto")
call(topics, listener)
assert listener.wait_for(lambda: contract.ERROR in listener.statuses("nursery-1"))
assert FakePP.triggers == []
assert contract.SENT_TO_SANCTUARY not in listener.statuses("nursery-1")
def test_a_malformed_payload_does_not_stop_the_service(rig):
coordinator, listener, settings, topics = rig
for junk in ["not json", "[1,2,3]", json.dumps({"no": "id"}), "\xff"]:
listener.publish(topics.request, junk)
time.sleep(0.5)
call(topics, listener, "nursery-after")
assert listener.wait_for(
lambda: contract.WAITING_APPROVAL in listener.statuses("nursery-after")
)
def test_a_stale_request_is_refused(rig):
coordinator, listener, settings, topics = rig
call(topics, listener, "nursery-old", age=9999)
assert listener.wait_for(
lambda: contract.EXPIRED in listener.statuses("nursery-old")
)
assert coordinator.state.queue == []
def test_duplicate_ids_do_not_send_twice(rig):
coordinator, listener, settings, topics = rig
listener.publish(topics.mode_set, "auto")
assert listener.wait_for(lambda: coordinator.state.mode == "auto")
call(topics, listener, "nursery-dup")
assert listener.wait_for(lambda: FakePP.triggers)
call(topics, listener, "nursery-dup")
time.sleep(1)
assert len(FakePP.triggers) == 1
def test_statuses_are_never_retained(rig):
"""A retained status would tell the next reader about a finished call."""
coordinator, listener, settings, topics = rig
call(topics, listener)
assert listener.wait_for(lambda: listener.statuses("nursery-1"))
later = Listener(settings.topic_root)
try:
time.sleep(1)
replayed = [t for t, _, retain in later.seen if "/status/" in t]
assert replayed == [], f"a status was retained: {replayed}"
finally:
later.stop()
def test_pending_is_retained_so_companion_sees_it_on_connect(rig):
coordinator, listener, settings, topics = rig
call(topics, listener)
assert listener.wait_for(lambda: coordinator.state.queue)
later = Listener(settings.topic_root)
try:
assert later.wait_for(lambda: later.latest(topics.pending) is not None)
assert later.latest(topics.pending)["id"] == "nursery-1"
finally:
later.stop()
def test_mode_is_retained_and_recovered(rig):
coordinator, listener, settings, topics = rig
listener.publish(topics.mode_set, "auto")
assert listener.wait_for(lambda: listener.latest(topics.mode) == "auto")
restarted = Coordinator(settings, ProPresenter(
settings.propresenter_base_url, "Parent Caller", "Name", "Room", timeout=3))
restarted.start()
try:
assert listener.wait_for(lambda: restarted.state.mode == "auto", timeout=8)
assert restarted.state.queue == [], "a restart must not inherit a queue"
finally:
restarted.stop()
def test_health_separates_the_service_from_its_dependencies(rig):
coordinator, listener, settings, topics = rig
report = coordinator.health()
assert report["alive"] is True
assert report["broker_connected"] is True
assert report["mode"] == "approval"
+180
View File
@@ -0,0 +1,180 @@
"""The ProPresenter adapter, against a mock of the real API.
The mock behaves the way the church's ProPresenter 21.3 was observed to
behave, including the part that matters most: an unknown token name
still returns 204.
"""
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from parent_call.propresenter import ProPresenter, ProPresenterError
UUID = "7D044452-B5A1-49F5-AC9C-1FD53A9A7353"
MESSAGES = [
{"id": {"name": "Gbreaker", "index": 0, "uuid": "FE2A"}, "tokens": []},
{
"id": {"name": "Parent Caller", "index": 2, "uuid": UUID},
"message": "{Name}'s parent please go to the {Room}",
"tokens": [
{"name": "Name", "text": {"text": ""}},
{"name": "Room", "text": {"text": ""}},
],
},
]
class Fake(BaseHTTPRequestHandler):
messages = MESSAGES
triggers = []
clears = []
trigger_status = 204
messages_status = 200
def log_message(self, *args):
pass
def _send(self, status, body=None):
self.send_response(status)
if body is None:
self.send_header("Content-Length", "0")
self.end_headers()
return
raw = json.dumps(body).encode()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
if self.path == "/version":
return self._send(200, {"host_description": "ProPresenter 21.3",
"api_version": "v1"})
if self.path == "/v1/messages":
if Fake.messages_status != 200:
return self._send(Fake.messages_status)
return self._send(200, Fake.messages)
if self.path.endswith("/clear"):
Fake.clears.append(self.path)
return self._send(204)
return self._send(404)
def do_POST(self):
if not self.path.endswith("/trigger"):
return self._send(404)
if UUID not in self.path:
return self._send(404)
length = int(self.headers.get("Content-Length", 0))
Fake.triggers.append(json.loads(self.rfile.read(length) or b"[]"))
# Exactly as the real one behaves: it does not check token names.
return self._send(Fake.trigger_status)
@pytest.fixture
def server():
Fake.messages = [dict(m) for m in MESSAGES]
Fake.triggers = []
Fake.clears = []
Fake.trigger_status = 204
Fake.messages_status = 200
httpd = HTTPServer(("127.0.0.1", 0), Fake)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{httpd.server_port}"
httpd.shutdown()
def adapter(base, child="Name", room="Room", message="Parent Caller"):
return ProPresenter(base, message, child, room, timeout=3)
def test_finds_the_message_and_its_tokens(server):
message = adapter(server).check()
assert message.uuid == UUID
assert message.tokens == ("Name", "Room")
def test_trigger_sends_both_tokens(server):
adapter(server).trigger("Emma", "Nursery")
assert Fake.triggers == [[
{"name": "Name", "text": {"text": "Emma"}},
{"name": "Room", "text": {"text": "Nursery"}},
]]
def test_both_tokens_always_go_even_though_only_one_changed(server):
"""ProPresenter keeps the previous value of any token left out, so a
partial trigger would show half of the call before it."""
pp = adapter(server)
pp.trigger("Emma", "Nursery")
pp.trigger("Noah", "Nursery")
assert [t["text"]["text"] for t in Fake.triggers[1]] == ["Noah", "Nursery"]
def test_a_wrong_token_name_is_refused_before_anything_is_triggered(server):
"""The whole reason this adapter reads the message first.
The real API returns 204 for an unknown token and puts the previous
child's name on screen. Nothing may be sent unless the tokens exist.
"""
with pytest.raises(ProPresenterError) as raised:
adapter(server, child="child", room="room").trigger("Emma", "Nursery")
assert "no token" in str(raised.value)
assert Fake.triggers == []
def test_a_missing_message_is_an_error_not_a_success(server):
with pytest.raises(ProPresenterError) as raised:
adapter(server, message="Parent Call").trigger("Emma", "Nursery")
assert "no message named" in str(raised.value)
assert Fake.triggers == []
def test_an_unreachable_propresenter_raises(server):
pp = ProPresenter("http://127.0.0.1:9", "Parent Caller", "Name", "Room", timeout=1)
with pytest.raises(ProPresenterError) as raised:
pp.trigger("Emma", "Nursery")
assert "cannot reach" in str(raised.value)
def test_a_non_2xx_trigger_is_an_error(server):
Fake.trigger_status = 500
with pytest.raises(ProPresenterError) as raised:
adapter(server).trigger("Emma", "Nursery")
assert "500" in str(raised.value)
def test_a_renamed_message_is_noticed_rather_than_cached_forever(server):
pp = adapter(server)
pp.trigger("Emma", "Nursery")
Fake.messages = [{"id": {"name": "Renamed", "uuid": UUID}, "tokens": []}]
with pytest.raises(ProPresenterError):
pp.trigger("Noah", "Nursery")
def test_clear_uses_the_resolved_uuid(server):
pp = adapter(server)
pp.trigger("Emma", "Nursery")
pp.clear()
assert Fake.clears == [f"/v1/message/{UUID}/clear"]
def test_no_address_configured_is_an_error(server):
with pytest.raises(ProPresenterError):
ProPresenter("", "Parent Caller", "Name", "Room").trigger("Emma", "Nursery")
+307
View File
@@ -0,0 +1,307 @@
"""The parent call state machine.
Every awkward case that matters in a real service: two rooms calling at
once, an approval for the wrong request, an approval that arrives after
the child has already been collected, a clear timer firing after the
next call has gone up.
"""
import pytest
from parent_call import contract
from parent_call.config import Settings
from parent_call.state import (
ClearNow,
Deliver,
ParentCall,
PublishPending,
PublishStatus,
ScheduleClear,
)
from parent_call.validation import Request
NOW = 1_780_000_000.0
@pytest.fixture
def settings():
return Settings(request_ttl=180, propresenter_clear_after=20, default_mode="approval")
def request(id="nursery-1", room="nursery", child="Emma", created_at=NOW):
return Request(id=id, room=room, child=child, created_at=created_at)
def statuses(effects):
return [e.status for e in effects if isinstance(e, PublishStatus)]
def deliveries(effects):
return [e for e in effects if isinstance(e, Deliver)]
def pendings(effects):
return [e for e in effects if isinstance(e, PublishPending)]
# -- mode off ----------------------------------------------------------
def test_off_refuses_and_does_not_queue(settings):
call = ParentCall(settings, mode=contract.MODE_OFF)
effects = call.submit(request(), NOW)
assert statuses(effects) == [contract.UNAVAILABLE]
assert deliveries(effects) == []
assert call.queue == []
def test_off_says_unavailable_rather_than_pretending(settings):
call = ParentCall(settings, mode=contract.MODE_OFF)
effect = call.submit(request(), NOW)[0]
assert contract.WORDING[effect.status] == "Parent Call is unavailable"
# -- mode auto ---------------------------------------------------------
def test_auto_delivers_immediately(settings):
call = ParentCall(settings, mode=contract.MODE_AUTO)
effects = call.submit(request(), NOW)
assert statuses(effects) == [contract.SUBMITTED, contract.SENDING]
assert len(deliveries(effects)) == 1
assert deliveries(effects)[0].request.child == "Emma"
assert call.queue == []
def test_auto_never_claims_more_than_sent(settings):
call = ParentCall(settings, mode=contract.MODE_AUTO)
sent = deliveries(call.submit(request(), NOW))[0]
effects = call.delivered(sent.request, sent.serial, True, None, NOW)
assert statuses(effects) == [contract.SENT_TO_SANCTUARY]
assert contract.WORDING[contract.SENT_TO_SANCTUARY] == "Sent to sanctuary"
assert "displayed" not in contract.WORDING[contract.SENT_TO_SANCTUARY].lower()
# -- mode approval -----------------------------------------------------
def test_approval_queues_and_waits(settings):
call = ParentCall(settings)
effects = call.submit(request(), NOW)
assert statuses(effects) == [contract.SUBMITTED, contract.WAITING_APPROVAL]
assert deliveries(effects) == []
assert [r.id for r in call.queue] == ["nursery-1"]
assert pendings(effects)[-1].request.id == "nursery-1"
def test_approval_sends_when_approved(settings):
call = ParentCall(settings)
call.submit(request(), NOW)
effects = call.approve("nursery-1", NOW)
assert statuses(effects) == [contract.SENDING]
assert len(deliveries(effects)) == 1
assert call.queue == []
assert pendings(effects)[-1].request is None
def test_rejection_is_reported(settings):
call = ParentCall(settings)
call.submit(request(), NOW)
effects = call.reject("nursery-1", NOW)
assert statuses(effects) == [contract.REJECTED]
assert deliveries(effects) == []
assert call.queue == []
# -- more than one room at once ---------------------------------------
def test_queue_is_fifo(settings):
call = ParentCall(settings)
call.submit(request(id="a", room="nursery", child="Emma"), NOW)
call.submit(request(id="b", room="k2", child="Noah"), NOW)
call.submit(request(id="c", room="older", child="Ava"), NOW)
assert [r.id for r in call.queue] == ["a", "b", "c"]
effects = call.approve("a", NOW)
assert pendings(effects)[-1].request.id == "b"
assert pendings(effects)[-1].waiting == 2
def test_approving_by_id_cannot_hit_the_wrong_child(settings):
"""Two rooms call at the same moment; the operator approves the
second. The first must stay exactly where it was."""
call = ParentCall(settings)
call.submit(request(id="a", room="nursery", child="Emma"), NOW)
call.submit(request(id="b", room="k2", child="Noah"), NOW)
effects = call.approve("b", NOW)
assert deliveries(effects)[0].request.child == "Noah"
assert [r.id for r in call.queue] == ["a"]
def test_approving_something_not_queued_does_nothing(settings):
call = ParentCall(settings)
call.submit(request(id="a"), NOW)
assert call.approve("does-not-exist", NOW) == []
assert [r.id for r in call.queue] == ["a"]
# -- duplicates --------------------------------------------------------
def test_the_same_id_is_never_delivered_twice(settings):
call = ParentCall(settings, mode=contract.MODE_AUTO)
first = call.submit(request(id="a"), NOW)
second = call.submit(request(id="a"), NOW)
assert len(deliveries(first)) == 1
assert deliveries(second) == []
def test_a_repeat_is_answered_again(settings):
"""A classroom that missed the status and retries should be told
where its request got to, not ignored."""
call = ParentCall(settings)
call.submit(request(id="a"), NOW)
repeat = call.submit(request(id="a"), NOW)
assert statuses(repeat) == [contract.WAITING_APPROVAL]
assert [r.id for r in call.queue] == ["a"]
# -- age ---------------------------------------------------------------
def test_a_stale_request_is_never_accepted(settings):
call = ParentCall(settings)
effects = call.submit(request(created_at=NOW - 9999), NOW)
assert statuses(effects) == [contract.EXPIRED]
assert call.queue == []
def test_a_queued_request_expires_on_its_own(settings):
call = ParentCall(settings)
call.submit(request(id="a"), NOW)
assert call.tick(NOW + 10) == []
effects = call.tick(NOW + settings.request_ttl + 1)
assert statuses(effects) == [contract.EXPIRED]
assert call.queue == []
assert pendings(effects)[-1].request is None
def test_an_approval_after_expiry_does_not_deliver(settings):
"""The operator was away; by the time they pressed approve the call
was stale. It must not go on screen."""
call = ParentCall(settings)
call.submit(request(id="a"), NOW)
effects = call.approve("a", NOW + settings.request_ttl + 1)
assert statuses(effects) == [contract.EXPIRED]
assert deliveries(effects) == []
# -- delivery outcome --------------------------------------------------
def test_propresenter_failure_is_never_a_success(settings):
call = ParentCall(settings, mode=contract.MODE_AUTO)
sent = deliveries(call.submit(request(), NOW))[0]
effects = call.delivered(sent.request, sent.serial, False, "cannot reach", NOW)
assert statuses(effects) == [contract.ERROR]
assert not any(isinstance(e, ScheduleClear) for e in effects)
assert effects[0].detail == "cannot reach"
def test_success_schedules_a_clear(settings):
call = ParentCall(settings, mode=contract.MODE_AUTO)
sent = deliveries(call.submit(request(), NOW))[0]
effects = call.delivered(sent.request, sent.serial, True, None, NOW)
scheduled = [e for e in effects if isinstance(e, ScheduleClear)]
assert scheduled and scheduled[0].after == 20
def test_clear_disabled_schedules_nothing():
settings = Settings(propresenter_clear_after=0, default_mode="auto")
call = ParentCall(settings, mode=contract.MODE_AUTO)
sent = deliveries(call.submit(request(), NOW))[0]
effects = call.delivered(sent.request, sent.serial, True, None, NOW)
assert not any(isinstance(e, ScheduleClear) for e in effects)
# -- the clear timer race ----------------------------------------------
def test_an_old_clear_cannot_wipe_a_newer_call(settings):
"""The nursery's call is still on screen when K2 calls. The nursery's
twenty second timer fires. It must not clear K2's call."""
call = ParentCall(settings, mode=contract.MODE_AUTO)
first = deliveries(call.submit(request(id="a", child="Emma"), NOW))[0]
call.delivered(first.request, first.serial, True, None, NOW)
second = deliveries(call.submit(request(id="b", child="Noah"), NOW))[0]
call.delivered(second.request, second.serial, True, None, NOW)
assert call.clear_due(first.serial) == []
due = call.clear_due(second.serial)
assert [type(e) for e in due] == [ClearNow]
# -- mode changes ------------------------------------------------------
def test_turning_off_empties_the_queue(settings):
call = ParentCall(settings)
call.submit(request(id="a"), NOW)
call.submit(request(id="b"), NOW)
effects = call.set_mode(contract.MODE_OFF, NOW)
assert statuses(effects) == [contract.UNAVAILABLE, contract.UNAVAILABLE]
assert call.queue == []
assert pendings(effects)[-1].request is None
def test_an_unknown_mode_is_ignored(settings):
call = ParentCall(settings)
assert call.set_mode("banana", NOW) == []
assert call.mode == contract.MODE_APPROVAL
# -- restart -----------------------------------------------------------
def test_a_new_coordinator_starts_empty(settings):
"""Whatever was queued when Control lost power is gone. A parent call
surfacing ten minutes late would be worse than losing it."""
call = ParentCall(settings)
call.submit(request(id="a"), NOW)
assert call.queue
restarted = ParentCall(settings)
assert restarted.queue == []
assert restarted.outcomes == {}
+132
View File
@@ -0,0 +1,132 @@
"""What a classroom is allowed to send.
This is the only place classroom input is read, and everything that
reaches a sanctuary screen comes through it.
"""
import json
import pytest
from parent_call.config import Settings
from parent_call.validation import Invalid, parse
NOW = 1_780_000_000.0
@pytest.fixture
def settings():
return Settings(max_child_length=40, max_room_length=32, request_ttl=180,
max_clock_skew=120)
def body(**fields):
payload = {"id": "nursery-1", "room": "nursery", "child": "Emma",
"created_at": NOW}
payload.update(fields)
return json.dumps(payload)
def test_a_good_request(settings):
request = parse(body(), NOW, settings)
assert (request.id, request.room, request.child) == ("nursery-1", "nursery", "Emma")
def test_only_the_four_fields_survive(settings):
"""A payload cannot smuggle anything through to ProPresenter."""
request = parse(
body(message="{Name} OWNS YOU", theme="evil", propresenter="anything"),
NOW, settings,
)
assert set(vars(request)) == {"id", "room", "child", "created_at"}
@pytest.mark.parametrize("payload, reason", [
(b"\xff\xfe", "payload is not utf-8"),
("{{{", "payload is not json"),
("[1,2,3]", "payload is not an object"),
(json.dumps({"room": "nursery", "child": "E", "created_at": NOW}), "id is missing"),
(json.dumps({"id": 7, "room": "n", "child": "E", "created_at": NOW}), "id is missing"),
])
def test_unusable_payloads(settings, payload, reason):
with pytest.raises(Invalid) as raised:
parse(payload, NOW, settings)
assert raised.value.reason == reason
@pytest.mark.parametrize("field, value", [
("id", "has/slash"),
("id", "has#hash"),
("id", "has+plus"),
("id", ""),
("id", "x" * 200),
])
def test_ids_that_could_reshape_a_topic(settings, field, value):
with pytest.raises(Invalid):
parse(body(**{field: value}), NOW, settings)
def test_the_id_is_reported_back_so_the_classroom_can_be_answered(settings):
with pytest.raises(Invalid) as raised:
parse(body(child=""), NOW, settings)
assert raised.value.request_id == "nursery-1"
assert raised.value.room == "nursery"
@pytest.mark.parametrize("room", ["a/b", "a+b", "a#b", "", "x" * 99, " "])
def test_rooms_that_are_not_rooms(settings, room):
with pytest.raises(Invalid):
parse(body(room=room), NOW, settings)
def test_room_is_lowercased(settings):
assert parse(body(room="Nursery"), NOW, settings).room == "nursery"
@pytest.mark.parametrize("child", ["", " ", "x" * 41])
def test_child_names_that_are_refused(settings, child):
with pytest.raises(Invalid):
parse(body(child=child), NOW, settings)
@pytest.mark.parametrize("child, expected", [
("Emma\nPASTOR SAYS EVACUATE", "Emma PASTOR SAYS EVACUATE"),
("Emma​Rose", "Emma Rose"),
("Emma‮", "Emma"),
(" Emma Rose ", "Emma Rose"),
("Zoé", "Zoé"),
])
def test_names_are_made_safe_to_put_on_a_screen(settings, child, expected):
"""Nothing invisible, and nothing that can push text around a screen,
survives into a sanctuary display."""
assert parse(body(child=child), NOW, settings).child == expected
def test_an_old_timestamp_is_still_a_valid_shape(settings):
"""How old is too old is policy, and the state machine decides it —
it has to make the same judgement again when a late approval arrives.
See test_state.test_a_stale_request_is_never_accepted."""
assert parse(body(created_at=NOW - 9999), NOW, settings).child == "Emma"
def test_requests_from_the_future(settings):
with pytest.raises(Invalid) as raised:
parse(body(created_at=NOW + 3600), NOW, settings)
assert "future" in raised.value.reason
def test_a_slightly_fast_classroom_clock_is_tolerated(settings):
assert parse(body(created_at=NOW + 60), NOW, settings).child == "Emma"
@pytest.mark.parametrize("created_at", [None, "soon", True, {}])
def test_timestamps_that_are_not_timestamps(settings, created_at):
with pytest.raises(Invalid) as raised:
parse(body(created_at=created_at), NOW, settings)
assert raised.value.reason == "created_at is missing"