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,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 == {}
|
||||
Reference in New Issue
Block a user