202 lines
7.1 KiB
Python
202 lines
7.1 KiB
Python
import json
|
|
import queue
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
|
|
import paho.mqtt.client as mqtt
|
|
|
|
from controller.config import ControllerConfig, MqttConfig
|
|
from controller.mqtt_adapter import MqttAdapter
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.published = []
|
|
self.subscriptions = []
|
|
self.acks = []
|
|
self.suppress_exceptions = False
|
|
self.connect_error = None
|
|
|
|
def username_pw_set(self, username, password):
|
|
self.calls.append(("username_pw_set", username, password))
|
|
|
|
def manual_ack_set(self, enabled):
|
|
self.calls.append(("manual_ack_set", enabled))
|
|
|
|
def max_queued_messages_set(self, count):
|
|
self.calls.append(("max_queued_messages_set", count))
|
|
|
|
def reconnect_delay_set(self, **kwargs):
|
|
self.calls.append(("reconnect_delay_set", kwargs))
|
|
|
|
def connect_async(self, *args, **kwargs):
|
|
self.calls.append(("connect_async", args, kwargs))
|
|
if self.connect_error is not None:
|
|
raise self.connect_error
|
|
return mqtt.MQTT_ERR_SUCCESS
|
|
|
|
def connect(self, *args, **kwargs):
|
|
self.calls.append(("connect", args, kwargs))
|
|
raise AssertionError("synchronous connect must never be used")
|
|
|
|
def loop_start(self):
|
|
self.calls.append(("loop_start",))
|
|
return mqtt.MQTT_ERR_SUCCESS
|
|
|
|
def loop_stop(self):
|
|
self.calls.append(("loop_stop",))
|
|
|
|
def disconnect(self):
|
|
self.calls.append(("disconnect",))
|
|
|
|
def subscribe(self, topic, qos=0):
|
|
self.subscriptions.append((topic, qos))
|
|
return mqtt.MQTT_ERR_SUCCESS, len(self.subscriptions)
|
|
|
|
def publish(self, topic, payload, qos=0, retain=False):
|
|
self.published.append((topic, payload, qos, retain))
|
|
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=len(self.published))
|
|
|
|
def ack(self, message_id, qos):
|
|
self.acks.append((message_id, qos))
|
|
return mqtt.MQTT_ERR_SUCCESS
|
|
|
|
|
|
class MqttAdapterTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.client = FakeClient()
|
|
self.events = queue.Queue()
|
|
self.config = ControllerConfig(
|
|
mqtt=MqttConfig(
|
|
host="broker",
|
|
port=1884,
|
|
username="user",
|
|
password="pass",
|
|
client_id="controller-test",
|
|
reconnect_min_seconds=1,
|
|
reconnect_max_seconds=2,
|
|
command_queue_size=8,
|
|
)
|
|
)
|
|
self.adapter = MqttAdapter(
|
|
self.config,
|
|
client=self.client,
|
|
events=self.events,
|
|
)
|
|
|
|
def tearDown(self):
|
|
self.adapter.stop()
|
|
|
|
def test_start_is_asynchronous_and_configures_manual_ack(self):
|
|
self.adapter.start()
|
|
names = [call[0] for call in self.client.calls]
|
|
self.assertIn("manual_ack_set", names)
|
|
self.assertIn("connect_async", names)
|
|
self.assertIn("loop_start", names)
|
|
self.assertNotIn("connect", names)
|
|
connect = next(call for call in self.client.calls if call[0] == "connect_async")
|
|
self.assertEqual(connect[1], ("broker", 1884))
|
|
self.assertEqual(connect[2], {"keepalive": 30})
|
|
|
|
def test_initial_setup_oserror_does_not_escape_startup(self):
|
|
self.client.connect_error = OSError("network unreachable")
|
|
self.adapter.start()
|
|
self.assertTrue(self.adapter.started)
|
|
self.assertIn("network unreachable", self.adapter.last_error)
|
|
self.assertNotIn("loop_start", [call[0] for call in self.client.calls])
|
|
|
|
def test_connect_subscribes_to_all_authoritative_inputs(self):
|
|
self.adapter.start()
|
|
self.adapter._on_connect(self.client, None, None, 0)
|
|
self.assertTrue(self.adapter.connected)
|
|
self.assertEqual(
|
|
self.client.subscriptions,
|
|
[
|
|
("youtube/player/status", 1),
|
|
("youtube/player/telemetry", 0),
|
|
("youtube/player/state", 0),
|
|
("youtube/controller/cmd", 1),
|
|
("audio/player/mode/state", 1),
|
|
],
|
|
)
|
|
event = self.events.get_nowait()
|
|
self.assertEqual(event.kind, "connected")
|
|
self.assertEqual(event.connection_generation, 1)
|
|
|
|
def test_player_commands_are_exact_qos_one_and_nonretained(self):
|
|
disconnected = self.adapter.publish_player_command({"action": "stop"})
|
|
self.assertFalse(disconnected.accepted)
|
|
self.adapter._on_connect(self.client, None, None, 0)
|
|
self.events.get_nowait()
|
|
receipt = self.adapter.publish_player_command(
|
|
{"action": "play", "slot": 3, "label": "Opening"}
|
|
)
|
|
self.assertTrue(receipt.accepted)
|
|
topic, payload, qos, retain = self.client.published[-1]
|
|
self.assertEqual(topic, "youtube/player/cmd")
|
|
self.assertEqual(json.loads(payload), {"action": "play", "slot": 3, "label": "Opening"})
|
|
self.assertEqual(qos, 1)
|
|
self.assertIs(retain, False)
|
|
|
|
def test_router_commands_are_qos_one_nonretained_plain_strings(self):
|
|
self.adapter._on_connect(self.client, None, None, 0)
|
|
self.events.get_nowait()
|
|
receipt = self.adapter.publish_router_mode("video")
|
|
self.assertTrue(receipt.accepted)
|
|
self.assertEqual(
|
|
self.client.published[-1],
|
|
("audio/player/mode/set", "video", 1, False),
|
|
)
|
|
|
|
def test_qos_message_is_acked_only_after_consumer_calls_acknowledge(self):
|
|
self.adapter._on_connect(self.client, None, None, 0)
|
|
self.events.get_nowait()
|
|
message = SimpleNamespace(
|
|
topic="youtube/controller/cmd",
|
|
payload=b'{"action":"stop"}',
|
|
qos=1,
|
|
retain=False,
|
|
mid=42,
|
|
)
|
|
self.adapter._on_message(self.client, None, message)
|
|
event = self.events.get_nowait()
|
|
self.assertEqual(self.client.acks, [])
|
|
self.assertEqual(event.message_id, 42)
|
|
self.assertTrue(self.adapter.acknowledge(event))
|
|
self.assertEqual(self.client.acks, [(42, 1)])
|
|
|
|
def test_stale_mid_from_previous_connection_is_not_acked(self):
|
|
self.adapter._on_connect(self.client, None, None, 0)
|
|
self.events.get_nowait()
|
|
message = SimpleNamespace(
|
|
topic="youtube/controller/cmd",
|
|
payload=b'{"action":"stop"}',
|
|
qos=1,
|
|
retain=False,
|
|
mid=7,
|
|
)
|
|
self.adapter._on_message(self.client, None, message)
|
|
old_event = self.events.get_nowait()
|
|
self.adapter._on_connect(self.client, None, None, 0)
|
|
self.events.get_nowait()
|
|
self.assertFalse(self.adapter.acknowledge(old_event))
|
|
self.assertEqual(self.client.acks, [])
|
|
|
|
def test_retained_flag_is_preserved_for_coordinator_rejection(self):
|
|
self.adapter._on_connect(self.client, None, None, 0)
|
|
self.events.get_nowait()
|
|
message = SimpleNamespace(
|
|
topic="youtube/controller/cmd",
|
|
payload=b'{"action":"delete_slot","slot":1}',
|
|
qos=1,
|
|
retain=True,
|
|
mid=8,
|
|
)
|
|
self.adapter._on_message(self.client, None, message)
|
|
self.assertIs(self.events.get_nowait().retain, True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|