Files
Classroom_Desktop/tests/test-classroom-mqtt.sh
ekklesiaandClaude Opus 5 dfd42f6fdf feat: give the rooms a line to the church broker
The first piece of turning these machines into connected endpoints.
classroom-mqtt is one small service per room that subscribes, validates
what arrives, and hands the work to commands that already exist.

Nothing in the room depends on it. A broker that is down, a network that
is missing, a machine with no broker configured at all — each is a
service sitting quietly, not a classroom that fails. Nothing waits for
it at boot and the shipped default is off, so every existing classroom
upgrades into this release with messaging disabled until a broker
exists.

The room is the hostname, which is already how these machines are named.
Topics carry no product name because the broker is the church's and
these are just rooms: classroom/notify/all, classroom/notify/<room>, and
a retained classroom/status/<room> so anyone asking later can be told
which rooms are up without waiting for them to say so again.
Notifications are events and are never retained; status is state and is.

Stale messages cannot come back, which is the property that matters most
and has two halves. The session is clean, so a broker never hands over a
backlog from while the room was off. And retained notifications are
refused on arrival, because otherwise one would replay into every room
at every reboot forever. The honest limit is written down: MQTT only
flags a retained message when it is replayed to a new subscription, so a
retained publish delivered live is indistinguishable from an ordinary
one and is shown once — which is right, since it is live at that moment,
and the replay is the actual hazard.

Delivery reuses the session-aware sender rather than a second copy of
it. classroom-notify-test grew into the real thing, so it is now
classroom-notify with the sample sender a wrapper over it, and its 36
tests came along.

The broker password lives in device.conf, so that file stops being world
readable: 0640 root:classroom, which everything that reads it already
satisfies.

Transport logic is kept free of any broker library so it can be tested
without one — 40 checks on room identity, the topic contract and which
payloads become notifications. Separately verified against a real
mosquitto: connect, subscribe, retained status, per-room and all-room
delivery, urgency, duplicate and expired and malformed messages ignored,
the broker killed with the daemon surviving, reconnect, and a stale
retained notification left on the broker not reaching the screen when a
room boots.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 14:41:49 -05:00

142 lines
5.4 KiB
Bash
Executable File

#!/bin/bash
set -uo pipefail
# The rules for what the classroom accepts from the broker: room
# identity, the topic contract, and which payloads become notifications.
# No broker involved — these are decisions, not plumbing.
#
# tests/test-classroom-mqtt.sh
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" python3 - <<'PY'
import json
import sys
import classroom_mqtt as m
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
print("1. the room is the machine")
check("hostname becomes the room", m.room_name({}, "nursery") == "nursery")
check("domain is dropped", m.room_name({}, "k2.church.local") == "k2")
check("case is normalised", m.room_name({}, "Older") == "older")
check("ROOM overrides the hostname",
m.room_name({"ROOM": "older"}, "box-17") == "older")
check("unsafe characters cannot reach a topic",
m.room_name({}, "Room 3!!") == "room-3")
check("a nameless machine still has a room",
m.room_name({}, "") == "unknown")
print()
print("2. the topic contract")
topics = m.notify_topics("nursery")
check("listens to every room", "classroom/notify/all" in topics)
check("listens to its own room", "classroom/notify/nursery" in topics)
check("status is per room", m.status_topic("nursery") == "classroom/status/nursery")
check("no product name in any topic",
not any("ekklesia" in t for t in topics + (m.status_topic("nursery"),)))
print()
print("3. a classroom without a broker stays quiet")
check("disabled means no broker", m.broker({"MQTT_HOST": "h"}) is None)
check("enabled with no host means no broker",
m.broker({"MQTT_ENABLED": "true"}) is None)
check("no config at all means no broker", m.broker({}) is None)
settings = m.broker({"MQTT_ENABLED": "yes", "MQTT_HOST": "10.40.0.2"})
check("host is taken", settings and settings["host"] == "10.40.0.2")
check("port defaults to 1883", settings and settings["port"] == 1883)
settings = m.broker({"MQTT_ENABLED": "1", "MQTT_HOST": "h", "MQTT_PORT": "not a port"})
check("a nonsense port falls back rather than crashing",
settings and settings["port"] == 1883)
print()
print("4. which messages become notifications")
now = 1_780_000_000
def payload(**fields):
body = {"title": "Sermon Starting", "message": "The sermon has started."}
body.update(fields)
return json.dumps(body)
n = m.Notifications()
note, reason = n.accept(payload(id="a1"), now=now)
check("a good message is accepted", reason == "ok", reason)
check("title survives", note and note["title"] == "Sermon Starting")
check("message survives", note and note["message"] == "The sermon has started.")
check("urgency defaults to normal", note and note["urgency"] == "normal")
note, reason = n.accept(payload(id="a2", urgency="critical"), now=now)
check("critical is honoured", note and note["urgency"] == "critical")
note, reason = n.accept(payload(id="a3", urgency="screaming"), now=now)
check("a nonsense urgency falls back to normal",
note and note["urgency"] == "normal")
print()
print("5. what gets dropped, without anything falling over")
n = m.Notifications()
for label, args, expected in [
("a retained notification is stale by definition",
{"payload": payload(id="r"), "retained": True}, "retained"),
("not JSON", {"payload": "{{{"}, "not json"),
("a JSON array is not a notification", {"payload": "[1,2,3]"}, "not an object"),
("no title", {"payload": json.dumps({"message": "x"})}, "no title"),
("a blank title", {"payload": json.dumps({"title": " "})}, "no title"),
("a title that is not text",
{"payload": json.dumps({"title": 7})}, "no title"),
("a message that is not text",
{"payload": json.dumps({"title": "t", "message": 7})}, "message is not text"),
("an expiry that is not a time",
{"payload": payload(expires_at="soon")}, "expires_at is not a time"),
("an id that is not text", {"payload": payload(id=7)}, "id is not text"),
("invalid utf-8", {"payload": b"\xff\xfe"}, "not utf-8"),
]:
note, reason = n.accept(now=now, **args)
check(label, note is None and reason == expected, f"got {reason}")
print()
print("6. expiry and duplicates")
n = m.Notifications()
note, reason = n.accept(payload(id="old", expires_at=now - 1), now=now)
check("an expired message is ignored", note is None and reason == "expired")
note, reason = n.accept(payload(id="future", expires_at=now + 60), now=now)
check("a live message is accepted", reason == "ok")
note, reason = n.accept(payload(id="future"), now=now)
check("the same id is not shown twice", note is None and reason == "duplicate")
note, reason = n.accept(payload(id="different"), now=now)
check("a different id still gets through", reason == "ok")
note, reason = n.accept(payload(), now=now)
check("a message with no id is not deduplicated", reason == "ok")
note, reason = n.accept(payload(), now=now)
check("and still is not", reason == "ok")
n = m.Notifications(remember=4)
for index in range(10):
n.accept(payload(id=f"m{index}"), now=now)
check("the id history stays bounded", len(n.seen) == 4, f"{len(n.seen)} kept")
note, reason = n.accept(payload(id="m0"), now=now)
check("an id forgotten long ago can come round again", reason == "ok")
print()
print(f"passed: {passed} failed: {failed}")
sys.exit(1 if failed else 0)
PY