Files
Ekkleksia_Parent_Call/tests/test_propresenter.py
T
Ekklesia f6c3bac732 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.
2026-09-06 15:15:11 -05:00

181 lines
5.5 KiB
Python

"""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")