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,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"),
|
||||
("EmmaRose", "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"
|
||||
Reference in New Issue
Block a user