Skip to content

Commit ab09594

Browse files
committed
update MQTT tests
1 parent e8d1343 commit ab09594

File tree

5 files changed

+115
-42
lines changed

5 files changed

+115
-42
lines changed

src/pms/extra/cli.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import sys
33
from dataclasses import fields
4-
from typing import Annotated
4+
from typing import Annotated, Optional
55

66
if sys.version_info >= (3, 10):
77
from typing import TypeAlias
@@ -58,11 +58,11 @@ def influxdb(
5858
MQTT_HOST: TypeAlias = Annotated[str, typer.Option("--mqtt-host", help="mqtt server")]
5959
MQTT_PORT: TypeAlias = Annotated[int, typer.Option("--mqtt-port", help="server port")]
6060
MQTT_USER: TypeAlias = Annotated[
61-
str,
61+
Optional[str],
6262
typer.Option("--mqtt-user", envvar="MQTT_USER", help="server username", show_default=False),
6363
]
6464
MQTT_PASS: TypeAlias = Annotated[
65-
str,
65+
Optional[str],
6666
typer.Option("--mqtt-pass", envvar="MQTT_PASS", help="server password", show_default=False),
6767
]
6868

@@ -72,8 +72,8 @@ def mqtt(
7272
topic: Annotated[str, typer.Option("--topic", "-t", help="mqtt root/topic")] = "homie/test",
7373
host: MQTT_HOST = "test.mosquitto.org",
7474
port: MQTT_PORT = 1883,
75-
user: MQTT_USER = "",
76-
word: MQTT_PASS = "",
75+
user: MQTT_USER = None,
76+
word: MQTT_PASS = None,
7777
):
7878
"""Read sensor and push PM measurements to a MQTT server"""
7979
try:
@@ -109,8 +109,8 @@ def bridge(
109109
mqtt_topic: MQTT_TOPIC = "homie/+/+/+",
110110
mqtt_host: MQTT_HOST = "test.mosquitto.org",
111111
mqtt_port: MQTT_PORT = 1883,
112-
mqtt_user: MQTT_USER = "",
113-
mqtt_pass: MQTT_PASS = "",
112+
mqtt_user: MQTT_USER = None,
113+
mqtt_pass: MQTT_PASS = None,
114114
db_host: DB_HOST = "influxdb",
115115
db_port: DB_PORT = 8086,
116116
db_user: DB_USER = "root",

src/pms/extra/mqtt.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88

99

1010
class Publisher(Protocol):
11-
def __call__(self, data: dict[str, int | str]) -> None: ...
11+
def __call__(self, data: dict[str, int | float | str]) -> None: ...
1212

1313

14-
def publisher(*, topic: str, host: str, port: int, username: str, password: str) -> Publisher:
14+
def publisher(
15+
*, topic: str, host: str, port: int, username: str | None, password: str | None
16+
) -> Publisher:
1517
"""returns function to publish to `topic` at `host`"""
1618
c = Client(client_id=topic)
1719
c.enable_logger(logger) # type:ignore[arg-type]
@@ -25,7 +27,7 @@ def publisher(*, topic: str, host: str, port: int, username: str, password: str)
2527
c.connect(host, port)
2628
c.loop_start()
2729

28-
def pub(data: dict[str, int | str]) -> None:
30+
def pub(data: dict[str, int | float | str]) -> None:
2931
for k, v in data.items():
3032
c.publish(f"{topic}/{k}", v, 1, True)
3133

@@ -38,6 +40,13 @@ class Data(NamedTuple):
3840
measurement: str
3941
value: float
4042

43+
def __str__(self):
44+
return f"{self.date:%F %T},{self.location},{self.measurement},{self.value}"
45+
46+
@property
47+
def date(self) -> datetime:
48+
return datetime.fromtimestamp(self.time)
49+
4150
@staticmethod
4251
def now() -> int:
4352
"""current time as seconds since epoch"""
@@ -63,8 +72,8 @@ def decode(cls, topic: str, payload: str, *, time: int | None = None) -> Data:
6372

6473
try:
6574
value = float(payload)
66-
except ValueError:
67-
raise UserWarning(f"non numeric payload: {payload}")
75+
except ValueError as e:
76+
raise UserWarning(f"non numeric payload: {payload}") from e
6877
else:
6978
return cls(time, location, measurement, value)
7079

@@ -73,8 +82,8 @@ def subscribe(
7382
topic: str,
7483
host: str,
7584
port: int,
76-
username: str,
77-
password: str,
85+
username: str | None,
86+
password: str | None,
7887
*,
7988
on_sensordata: Callable[[Data], None],
8089
) -> None:

tests/conftest.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ class CapturedData(Enum):
6464
"""Captured data from tests/captured_data"""
6565

6666
_ignore_ = "name capt CapturedData reader"
67+
value: tuple[RawData]
6768

69+
CapturedData = vars()
6870
with captured_data_reader(data=CAPTURED_DATA) as reader:
69-
CapturedData = vars()
7071
for name in (s.name for s in Sensor):
71-
capt = tuple(reader(name))
72-
if capt:
72+
if capt := tuple(reader(name)):
7373
CapturedData[name] = capt
7474

7575
def __str__(self) -> str:
@@ -106,6 +106,7 @@ def msg_hex(self) -> Iterator[str]:
106106
yield message.hex
107107

108108
def samples(self, command: str) -> int:
109+
logger.debug(f"{self.name} {len(self.value)} obs")
109110
return len(self.value) - (command == "mqtt")
110111

111112
def options(self, command: str, *, debug: bool = False) -> list[str]:
@@ -151,11 +152,10 @@ def captured_data(request: pytest.FixtureRequest) -> CapturedData:
151152

152153
@pytest.fixture
153154
def replay_time(monkeypatch: pytest.MonkeyPatch, captured_data: CapturedData) -> None:
154-
"""mock datetime at `pms.core.sensor`, `pms.sensors.base` and `pms.sensors.mqtt`"""
155-
captured_data = captured_data
155+
"""mock datetime at `pms.core.sensor` and `pms.sensors.base`"""
156156

157157
class mock_datetime(datetime):
158-
message_timestamp = captured_data.message_timestamp
158+
_timestamp = captured_data.message_timestamp
159159

160160
@classmethod
161161
def fromtimestamp(cls, t, tz=captured_data.tzinfo):
@@ -164,11 +164,10 @@ def fromtimestamp(cls, t, tz=captured_data.tzinfo):
164164

165165
@classmethod
166166
def now(cls, tz=captured_data.tzinfo):
167-
return cls.fromtimestamp(next(cls.message_timestamp), tz)
167+
return cls.fromtimestamp(next(cls._timestamp), tz)
168168

169169
monkeypatch.setattr("pms.core.sensor.datetime", mock_datetime)
170170
monkeypatch.setattr("pms.sensors.base.datetime", mock_datetime)
171-
monkeypatch.setattr("pms.extra.mqtt.datetime", mock_datetime)
172171

173172

174173
@pytest.fixture

tests/extra/test_cli.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

3+
from collections.abc import Iterator
34
from dataclasses import fields
45
from typing import Callable
56

67
import pytest
8+
from loguru import logger
79
from typer.testing import CliRunner
810

911
from pms.main import main
@@ -12,21 +14,64 @@
1214

1315

1416
@pytest.fixture()
15-
def mock_mqtt_publisher(monkeypatch: pytest.MonkeyPatch):
16-
"""mock pms.extra.mqtt.publisher"""
17-
from pms.extra.mqtt import Publisher
17+
def mock_mqtt_client(captured_data, monkeypatch: pytest.MonkeyPatch):
18+
from pms.extra.mqtt import Data
19+
20+
def mqtt_messages() -> Iterator[Data]:
21+
for obs in captured_data.obs:
22+
for field in fields(obs):
23+
if not field.metadata:
24+
continue
25+
yield Data(obs.time, "test", field.name, getattr(obs, field.name))
26+
27+
class MockClient:
28+
_message = mqtt_messages()
29+
30+
def __init__(self, *, client_id: str):
31+
assert client_id in {"homie/test", "homie/+/+/+"}
1832

19-
def publisher(*, topic: str, host: str, port: int, username: str, password: str) -> Publisher:
20-
def pub(data: dict[str, int | str]) -> None:
33+
def enable_logger(self, logger): # noqa: F811
2134
pass
2235

23-
return pub
36+
def username_pw_set(self, username: str | None, password: str | None = None):
37+
assert username is None
38+
assert password is None
39+
40+
def will_set(self, topic, payload=None, qos=0, retain=False, properties=None):
41+
assert topic.endswith("$online")
42+
assert payload == "false"
43+
assert qos == 1
44+
assert retain is True
45+
46+
def connect(self, host, port=1883):
47+
assert host == "test.mosquitto.org"
48+
assert port == 1883 # MQTT, unencrypted, unauthenticated
49+
50+
def publish(self, topic, payload=None, qos=0, retain=False, properties=None):
51+
logger.debug(f"{topic} = {payload}")
52+
assert topic.startswith("homie/test/")
53+
assert isinstance(payload, (str, float, int))
54+
assert qos == 1
55+
assert retain is True
56+
if isinstance(payload, (float, int)):
57+
logger.debug(msg := next(self._message))
58+
assert Data.decode(topic, payload, time=msg.time) == msg
59+
60+
def _handle_on_message(self, message):
61+
logger.debug("_handle_on_message")
62+
assert self.on_message is not None
63+
64+
def loop_start(self):
65+
logger.debug("loop_start")
66+
67+
def loop_forever(self, timeout=1, retry_first_connection=False):
68+
logger.debug("loop_forever")
2469

25-
monkeypatch.setattr("pms.extra.mqtt.publisher", publisher)
70+
monkeypatch.setattr("pms.extra.mqtt.Client", MockClient)
2671

2772

2873
@pytest.fixture()
29-
def mock_mqtt_subscribe(captured_data, monkeypatch: pytest.MonkeyPatch, replay_time):
74+
def mock_mqtt_subscribe(captured_data, monkeypatch: pytest.MonkeyPatch):
3075
"""mock ms.extra.mqtt.subscribe"""
3176
from pms.extra.mqtt import Data
3277

@@ -60,16 +105,16 @@ def publisher(*, host: str, port: int, username: str, password: str, db_name: st
60105
def pub(*, time: int, tags: dict[str, str], data: dict[str, float]) -> None:
61106
tag = ",".join(f"{k},{v}" for k, v in tags.items())
62107
for key, val in data.items():
63-
print(f"{time},{tag},{key},{val}")
108+
logger.debug(f"{time},{tag},{key},{val}")
64109

65110
return pub
66111

67112
monkeypatch.setattr("pms.extra.influxdb.publisher", publisher)
68113

69114

70-
@pytest.mark.usefixtures("mock_mqtt_publisher")
115+
@pytest.mark.usefixtures("mock_mqtt_client")
71116
def test_mqtt(capture):
72-
result = runner.invoke(main, capture.options("mqtt"))
117+
result = runner.invoke(main, capture.options("mqtt", debug=True))
73118
assert result.exit_code == 0
74119

75120

tests/extra/test_mqtt.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,30 +20,50 @@ def test_decode(location, measurement, value, secs=1_567_201_793):
2020
@pytest.mark.parametrize(
2121
"topic,payload,error",
2222
[
23-
pytest.param("short/topic", "27.00", "topic total length: 2", id="short topic"),
24-
pytest.param("too/long/topic/+/+/+", "27.00", "topic total length: 6", id="long topic"),
23+
pytest.param(
24+
"short/topic",
25+
"27.00",
26+
r"topic total length: 2",
27+
id="short topic",
28+
),
29+
pytest.param(
30+
"too/long/topic/+/+/+",
31+
"27.00",
32+
"topic total length: 6",
33+
id="long topic",
34+
),
2535
pytest.param(
2636
"sneaky/system/topic/$+",
2737
"27.00",
28-
"system topic: sneaky/system/topic/$+",
38+
r"system topic: sneaky/system/topic/\$\+",
2939
id="system topic",
3040
),
3141
pytest.param(
3242
"other/$system/topic/+",
3343
"27.00",
34-
"system topic: other/$system/topic/+",
44+
r"system topic: other/\$system/topic/\+",
3545
id="system topic",
3646
),
37-
pytest.param("non/numeric/payload/+", "OK", "non numeric payload: OK", id="NaN payload"),
3847
pytest.param(
39-
"non/numeric/payload/+", "value27", "non numeric payload: value27", id="NaN payload"
48+
"non/numeric/payload/+",
49+
"OK",
50+
r"non numeric payload: OK",
51+
id="NaN payload",
52+
),
53+
pytest.param(
54+
"non/numeric/payload/+",
55+
"value27",
56+
r"non numeric payload: value27",
57+
id="NaN payload",
4058
),
4159
pytest.param(
42-
"non/numeric/payload/+", "27,00", "non numeric payload: 27,00", id="NaN payload"
60+
"non/numeric/payload/+",
61+
"27,00",
62+
r"non numeric payload: 27,00",
63+
id="NaN payload",
4364
),
4465
],
4566
)
4667
def test_decode_error(topic, payload, error, secs=1_567_201_793):
47-
with pytest.raises(Exception) as e:
68+
with pytest.raises(UserWarning, match=error):
4869
mqtt.Data.decode(topic, payload, time=secs)
49-
assert str(e.value) == error

0 commit comments

Comments
 (0)