🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-08-31 21:13:38 +02:00
commit 26a027d1e8
26 changed files with 1544 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
"""Publish sample JSON messages to /<prefix>/<imei> topics.
Usage:
python tools/publish_test_messages.py --broker host:1883 \
[--prefix "vivab"] [--count 5] [--no-timestamp]
Requires paho-mqtt (see tools/requirements.txt).
"""
from __future__ import annotations
import argparse
import json
import random
import time
from datetime import datetime, timezone
from typing import Any
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
# Sample IMEIs; the first two exist in configs/listener/imei_map.json.
SAMPLE_IMEIS = [
("350123456789012", "Greenhouse sensor A"),
("490154203237518", "Water tank sensor B"),
("356938035643809", "Weather station roof"),
("861234012345678", "Soil moisture plot 12"),
]
def build_payload(imei: str, seq: int, with_timestamp: bool = True) -> dict[str, Any]:
payload: dict[str, Any] = {
"temperature": round(random.uniform(15, 30), 2),
"humidity": round(random.uniform(40, 90), 2),
"battery_mv": random.randint(2800, 4200),
"seq": seq,
}
if with_timestamp:
# ISO 8601 with explicit UTC offset, like a real device clock
payload["timestamp"] = datetime.now(timezone.utc).isoformat()
return payload
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--broker", default="localhost:1883", help="host:port of the MQTT broker")
parser.add_argument("--prefix", default="vivab", help="topic prefix: /<prefix>/<imei>")
parser.add_argument("--count", type=int, default=5, help="messages per IMEI")
parser.add_argument("--qos", type=int, default=1, choices=(0, 1, 2))
parser.add_argument("--no-timestamp", action="store_true", help="publish without a timestamp property")
args = parser.parse_args()
host, _, port = args.broker.partition(":")
port = port or "1883"
client = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, client_id="test-publisher")
client.connect(host, int(port), keepalive=60)
client.loop_start()
try:
seq = 0
for imei, _name in SAMPLE_IMEIS:
for _ in range(args.count):
seq += 1
topic = f"/{args.prefix}/{imei}"
payload = json.dumps(build_payload(imei, seq, with_timestamp=not args.no_timestamp))
info = client.publish(topic, payload, qos=args.qos)
info.wait_for_publish(timeout=5)
print(f"published {topic}: {payload}")
time.sleep(0.1)
finally:
client.loop_stop()
client.disconnect()
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
# Local development / test tooling. The container images only install their
# own service requirements; these extras are for running tests and tools on
# the host.
fastapi==0.115.12
uvicorn[standard]==0.34.2
psycopg[binary]==3.2.3
paho-mqtt==2.1.0
pytest==8.3.5
+101
View File
@@ -0,0 +1,101 @@
"""End-to-end smoke test against a running stack.
Checks:
1. API rejects requests without a token (401)
2. API rejects requests with a wrong token (401)
3. API accepts a valid token and returns stored messages
4. Time framing: a wide from/to window is accepted, a reversed one is rejected
Usage:
python tools/smoke_test_api.py --url http://localhost:18080 --token <secret>
Only uses the standard library.
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.request
from urllib.parse import quote
def request(url: str, token: str | None) -> tuple[int, str]:
req = urllib.request.Request(url)
if token is not None:
req.add_header("X-App-Token", token)
try:
with urllib.request.urlopen(req, timeout=10) as response:
return response.status, response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", errors="replace")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default="http://localhost:8080")
parser.add_argument("--token", required=True, help="valid app token")
args = parser.parse_args()
failures: list[str] = []
status, _ = request(f"{args.url}/messages?limit=1", token=None)
if status == 401:
print("PASS: missing token rejected with 401")
else:
failures.append(f"missing token -> expected 401, got {status}")
status, _ = request(f"{args.url}/messages?limit=1", token="definitely-wrong-token")
if status == 401:
print("PASS: wrong token rejected with 401")
else:
failures.append(f"wrong token -> expected 401, got {status}")
status, body = request(f"{args.url}/messages?limit=3", token=args.token)
if status == 200:
data = json.loads(body)
print(f"PASS: valid token accepted, {len(data['items'])} message(s) returned")
for item in data["items"]:
print(f" - {item['received_at']} {item['device']}: {json.dumps(item['payload'])}")
else:
failures.append(f"valid token -> expected 200, got {status}: {body}")
status, body = request(f"{args.url}/messages/count", token=args.token)
if status == 200:
print(f"PASS: count endpoint returned {json.loads(body)}")
else:
failures.append(f"count endpoint -> expected 200, got {status}: {body}")
wide_from = "2000-01-01T00:00:00Z"
wide_to = "2100-01-01T00:00:00Z"
status, body = request(
f"{args.url}/messages?limit=1&from={quote(wide_from)}&to={quote(wide_to)}",
token=args.token,
)
if status == 200:
print("PASS: wide from/to window accepted")
else:
failures.append(f"wide from/to window -> expected 200, got {status}: {body}")
status, body = request(
f"{args.url}/messages?limit=1&from={quote(wide_to)}&to={quote(wide_from)}",
token=args.token,
)
if status == 422:
print("PASS: reversed from/to window rejected with 422")
else:
failures.append(f"reversed from/to window -> expected 422, got {status}: {body}")
if failures:
print("\nFAILURES:")
for failure in failures:
print(f" - {failure}")
return 1
print("\nAll smoke tests passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
+292
View File
@@ -0,0 +1,292 @@
"""Unit tests for listener.process and listener.main config loading."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from listener.main import load_imei_map # noqa: E402
from listener.process import ( # noqa: E402
InjectionResult,
parse_payload_time,
process_message,
)
from datetime import datetime, timezone # noqa: E402
IMEI_MAP = {
"350123456789012": "Greenhouse sensor A",
"490154203237518": "Water tank sensor B",
}
class TestNormalizeImei:
@pytest.mark.parametrize(
("value", "expected"),
[
("350123456789012", "350123456789012"),
("490154203237518", "490154203237518"),
],
)
def test_valid(self, value: str, expected: str) -> None:
result = process_message(f"/up/{value}", b"{}", IMEI_MAP)
assert result is not None
assert result.imei == expected
@pytest.mark.parametrize(
"value",
[
"",
"too-short",
"3501234567890123", # 16 digits
"0011223344556677", # hex (old dev EUI form) - no longer valid
"35012345678901a", # letter in last position
"35012345678901", # 14 digits
"350-123-456-789-012", # separators not allowed
],
)
def test_invalid(self, value: str) -> None:
assert process_message(f"/up/{value}", b"{}", IMEI_MAP) is None
class TestProcessMessage:
def test_injects_imei_and_mapped_device(self) -> None:
payload = json.dumps({"temperature": 21.5}).encode()
result = process_message("/vivab/350123456789012", payload, IMEI_MAP)
assert result == InjectionResult(
imei="350123456789012",
device="Greenhouse sensor A",
payload={"temperature": 21.5, "imei": "350123456789012", "device": "Greenhouse sensor A"},
)
def test_unmapped_imei_falls_back_to_imei(self) -> None:
payload = json.dumps({"temperature": 21.5}).encode()
result = process_message("/vivab/356938035643809", payload, IMEI_MAP)
assert result is not None
assert result.device == "356938035643809"
assert result.payload["device"] == "356938035643809"
def test_overwrites_existing_keys(self) -> None:
payload = json.dumps({"imei": "fake", "device": "fake", "seq": 1}).encode()
result = process_message("/up/490154203237518", payload, IMEI_MAP)
assert result is not None
assert result.payload["imei"] == "490154203237518"
assert result.payload["device"] == "Water tank sensor B"
def test_does_not_mutate_caller_payload(self) -> None:
original = {"seq": 1}
result = process_message("/up/350123456789012", json.dumps(original).encode(), IMEI_MAP)
assert result is not None
assert original == {"seq": 1}
def test_invalid_json_returns_none(self) -> None:
assert process_message("/up/350123456789012", b"not json", IMEI_MAP) is None
def test_non_object_json_returns_none(self) -> None:
assert process_message("/up/350123456789012", b"[1,2,3]", IMEI_MAP) is None
def test_custom_keys(self) -> None:
payload = json.dumps({"seq": 1}).encode()
result = process_message(
"/up/350123456789012", payload, IMEI_MAP, imei_key="id", device_key="name"
)
assert result is not None
assert result.payload["id"] == "350123456789012"
assert result.payload["name"] == "Greenhouse sensor A"
class TestParsePayloadTime:
def test_iso_string_with_offset(self) -> None:
parsed = parse_payload_time("2026-08-31T12:34:56+02:00")
assert parsed == datetime(2026, 8, 31, 10, 34, 56, tzinfo=timezone.utc)
def test_iso_string_naive_treated_as_utc(self) -> None:
parsed = parse_payload_time("2026-08-31T12:34:56")
assert parsed == datetime(2026, 8, 31, 12, 34, 56, tzinfo=timezone.utc)
def test_iso_string_with_z(self) -> None:
parsed = parse_payload_time("2026-08-31T12:34:56Z")
assert parsed == datetime(2026, 8, 31, 12, 34, 56, tzinfo=timezone.utc)
def test_epoch_seconds(self) -> None:
parsed = parse_payload_time(1756646096)
assert parsed == datetime.fromtimestamp(1756646096, tz=timezone.utc)
def test_epoch_milliseconds_detected(self) -> None:
# ms magnitude (>1e11) must be divided by 1000
parsed = parse_payload_time(1756646096123)
assert parsed == datetime.fromtimestamp(1756646096.123, tz=timezone.utc)
def test_numeric_string_epoch(self) -> None:
parsed = parse_payload_time("1756646096")
assert parsed == datetime.fromtimestamp(1756646096, tz=timezone.utc)
def test_rejects_bool(self) -> None:
# bool is an int subclass; True must not parse as epoch 1
assert parse_payload_time(True) is None
assert parse_payload_time(False) is None
@pytest.mark.parametrize("value", [None, "", "not-a-date", "12:34", [], {}])
def test_rejects_garbage(self, value: object) -> None:
assert parse_payload_time(value) is None
class TestPayloadTimeExtraction:
def test_timestamp_extracted_from_payload(self) -> None:
payload = json.dumps({"temperature": 21.5, "timestamp": "2026-08-31T09:59:58Z"}).encode()
result = process_message("/up/350123456789012", payload, IMEI_MAP)
assert result is not None
assert result.payload_time == datetime(2026, 8, 31, 9, 59, 58, tzinfo=timezone.utc)
# the original payload value stays untouched
assert result.payload["timestamp"] == "2026-08-31T09:59:58Z"
def test_first_parseable_key_wins(self) -> None:
payload = json.dumps({"timestamp": "garbage", "time": 1756646096}).encode()
result = process_message("/up/350123456789012", payload, IMEI_MAP)
assert result is not None
assert result.payload_time == datetime.fromtimestamp(1756646096, tz=timezone.utc)
def test_custom_time_keys(self) -> None:
payload = json.dumps({"measured_at": "2026-08-31T09:59:58Z"}).encode()
result = process_message(
"/up/350123456789012", payload, IMEI_MAP, time_keys=("measured_at",)
)
assert result is not None
assert result.payload_time == datetime(2026, 8, 31, 9, 59, 58, tzinfo=timezone.utc)
def test_missing_timestamp_yields_none_not_skip(self) -> None:
payload = json.dumps({"temperature": 21.5}).encode()
result = process_message("/up/350123456789012", payload, IMEI_MAP)
assert result is not None # message still stored
assert result.payload_time is None
def test_unparseable_timestamp_yields_none_not_skip(self) -> None:
payload = json.dumps({"timestamp": "yesterday-ish"}).encode()
result = process_message("/up/350123456789012", payload, IMEI_MAP)
assert result is not None
assert result.payload_time is None
class TestLoadImeiMap:
def test_loads_valid_map(self, tmp_path: Path) -> None:
path = tmp_path / "map.json"
path.write_text(
json.dumps({"350123456789012": "Device X"}), encoding="utf-8"
)
loaded = load_imei_map(str(path))
assert loaded == {"350123456789012": "Device X"}
def test_rejects_non_object(self, tmp_path: Path) -> None:
path = tmp_path / "map.json"
path.write_text("[]", encoding="utf-8")
with pytest.raises(ValueError, match="expected a JSON object"):
load_imei_map(str(path))
def test_rejects_non_string_values(self, tmp_path: Path) -> None:
path = tmp_path / "map.json"
path.write_text(json.dumps({"350123456789012": 42}), encoding="utf-8")
with pytest.raises(ValueError, match="non-empty strings"):
load_imei_map(str(path))
def test_rejects_non_imei_key(self, tmp_path: Path) -> None:
path = tmp_path / "map.json"
# Old dev EUI-style key: no longer accepted
path.write_text(json.dumps({"0011223344556677": "Device X"}), encoding="utf-8")
with pytest.raises(ValueError, match="15-digit IMEI"):
load_imei_map(str(path))
class TestApiAuth:
"""Light tests of the API auth dependency using the ASGI app directly."""
@pytest.fixture()
def app_module(self, tmp_path):
import importlib
import os
# api.app runs create_app() at import time, so the environment must be
# prepared before importing. connect_timeout keeps the DB-connecting
# tests fast even when localhost:5432 is silently dropped by a firewall
# (psycopg otherwise waits out the OS-level TCP timeout).
tokens = tmp_path / "app_tokens.json"
tokens.write_text(
json.dumps({"tokens": {"test-app": "secret-abc"}}), encoding="utf-8"
)
env = {
"APP_TOKENS_FILE": str(tokens),
"DB_DSN": "postgresql://user:pass@localhost:5432/db?connect_timeout=2",
}
old = {key: os.environ.get(key) for key in env}
os.environ.update(env)
try:
import api.app as app_module
importlib.reload(app_module)
yield app_module
finally:
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def test_health_is_public(self, app_module) -> None:
from fastapi.testclient import TestClient
client = TestClient(app_module.app)
assert client.get("/health").status_code == 405 or client.get("/health").json() == {"status": "ok"}
def test_missing_token_rejected(self, app_module) -> None:
from fastapi.testclient import TestClient
client = TestClient(app_module.app)
response = client.get("/messages?limit=1")
assert response.status_code == 401
def test_wrong_token_rejected(self, app_module) -> None:
from fastapi.testclient import TestClient
client = TestClient(app_module.app)
response = client.get("/messages?limit=1", headers={"X-App-Token": "nope"})
assert response.status_code == 401
def test_valid_token_accepted(self, app_module) -> None:
from fastapi.testclient import TestClient
# The DB is not reachable in unit tests, so a valid token proceeds past
# auth and then fails on the database connection with a 500. That
# proves authentication passed (401 would mean it did not).
client = TestClient(app_module.app, raise_server_exceptions=False)
response = client.get("/messages?limit=1", headers={"X-App-Token": "secret-abc"})
assert response.status_code != 401
def test_reversed_time_frame_rejected(self, app_module) -> None:
from fastapi.testclient import TestClient
# Range validation runs before any database access, so this works
# without a reachable DB.
client = TestClient(app_module.app)
response = client.get(
"/messages",
params={"from": "2026-08-31T12:00:00Z", "to": "2026-08-31T11:00:00Z"},
headers={"X-App-Token": "secret-abc"},
)
assert response.status_code == 422
def test_naive_and_offset_timestamps_accepted(self, app_module) -> None:
from fastapi.testclient import TestClient
# Both forms must parse; the request fails later on DB access (500),
# never with a 422 validation error.
client = TestClient(app_module.app, raise_server_exceptions=False)
for value in ("2026-08-31T12:00:00", "2026-08-31T12:00:00+02:00"):
response = client.get(
"/messages",
params={"from": value},
headers={"X-App-Token": "secret-abc"},
)
assert response.status_code != 422