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:
@@ -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")
|
||||
Reference in New Issue
Block a user