🎉 Initial commit
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user