🚧 Add imei time varying attributes
This commit is contained in:
@@ -7,6 +7,7 @@ Checks:
|
||||
4. Time framing: a wide from/to window is accepted, a reversed one is rejected
|
||||
5. Device resources: a scratch device is upserted, read back and deleted
|
||||
6. Token resources: a scratch token authenticates and is then removed
|
||||
7. Device attributes: an offset timeline is appended and resolved
|
||||
|
||||
Usage:
|
||||
python tools/smoke_test_api.py --url http://localhost:18080 --token <secret>
|
||||
@@ -166,6 +167,52 @@ def main() -> int:
|
||||
else:
|
||||
failures.append(f"removed token auth -> expected 401, got {status}: {body}")
|
||||
|
||||
# 7. Device attributes: append an offset timeline, resolve it, delete it
|
||||
attrs_url = f"{args.url}/devices/{scratch_imei}/attributes"
|
||||
for value, effective in ((1.5, "2000-01-01T00:00:00Z"), (2.5, "2020-01-01T00:00:00Z")):
|
||||
status, body = request(
|
||||
attrs_url,
|
||||
token=args.token,
|
||||
method="PUT",
|
||||
data=json.dumps({"key": "offset", "value": value, "effective_from": effective}).encode(),
|
||||
)
|
||||
if status == 200:
|
||||
print(f"PASS: attribute version {value} appended")
|
||||
else:
|
||||
failures.append(f"attribute append {value} -> expected 200, got {status}: {body}")
|
||||
|
||||
status, body = request(
|
||||
f"{attrs_url}?at={quote('2010-06-01T00:00:00Z')}", token=args.token
|
||||
)
|
||||
if status == 200 and json.loads(body)["attributes"] == {"offset": 1.5}:
|
||||
print("PASS: attribute resolved as of 2010 (first version)")
|
||||
else:
|
||||
failures.append(f"attribute resolve at 2010 -> expected offset 1.5, got {status}: {body}")
|
||||
|
||||
status, body = request(attrs_url, token=args.token)
|
||||
if status == 200 and json.loads(body)["attributes"] == {"offset": 2.5}:
|
||||
print("PASS: attribute resolved as of now (latest version)")
|
||||
else:
|
||||
failures.append(f"attribute resolve now -> expected offset 2.5, got {status}: {body}")
|
||||
|
||||
status, body = request(f"{attrs_url}/history", token=args.token)
|
||||
if status == 200 and [v["value"] for v in json.loads(body)["attributes"]["offset"]] == [1.5, 2.5]:
|
||||
print("PASS: attribute history lists both versions oldest first")
|
||||
else:
|
||||
failures.append(f"attribute history -> expected [1.5, 2.5], got {status}: {body}")
|
||||
|
||||
status, body = request(f"{attrs_url}/offset", token=args.token, method="DELETE")
|
||||
if status == 200:
|
||||
print("PASS: latest attribute version deleted")
|
||||
else:
|
||||
failures.append(f"attribute delete -> expected 200, got {status}: {body}")
|
||||
|
||||
status, body = request(attrs_url, token=args.token)
|
||||
if status == 200 and json.loads(body)["attributes"] == {"offset": 1.5}:
|
||||
print("PASS: earlier attribute version back in effect")
|
||||
else:
|
||||
failures.append(f"attribute after delete -> expected offset 1.5, got {status}: {body}")
|
||||
|
||||
if failures:
|
||||
print("\nFAILURES:")
|
||||
for failure in failures:
|
||||
|
||||
+305
-8
@@ -18,7 +18,14 @@ os.environ.setdefault(
|
||||
"DB_DSN", "postgresql://user:pass@localhost:5432/db?connect_timeout=2"
|
||||
)
|
||||
|
||||
from api.app import ImeiMapStore, TokenStore, _validate_token_body, hash_token # noqa: E402
|
||||
from api.app import ( # noqa: E402
|
||||
ImeiAttributesStore,
|
||||
ImeiMapStore,
|
||||
TokenStore,
|
||||
_validate_attribute_body,
|
||||
_validate_token_body,
|
||||
hash_token,
|
||||
)
|
||||
from listener.process import ( # noqa: E402
|
||||
InjectionResult,
|
||||
parse_payload_time,
|
||||
@@ -348,6 +355,53 @@ class TestTokenHelpers:
|
||||
_validate_token_body({"tokens": {"app": ""}}, "test")
|
||||
|
||||
|
||||
class TestValidateAttributeBody:
|
||||
"""_validate_attribute_body is the schema check for attribute appends."""
|
||||
|
||||
def test_accepts_full_body(self) -> None:
|
||||
key, value, effective_from, note = _validate_attribute_body(
|
||||
{"key": "temperature_offset", "value": 2.5,
|
||||
"effective_from": "2026-08-15T00:00:00Z", "note": "after sensor swap"},
|
||||
"test",
|
||||
)
|
||||
assert key == "temperature_offset"
|
||||
assert value == 2.5
|
||||
assert effective_from == datetime(2026, 8, 15, tzinfo=timezone.utc)
|
||||
assert note == "after sensor swap"
|
||||
|
||||
def test_effective_from_defaults_to_none_for_now(self) -> None:
|
||||
key, value, effective_from, note = _validate_attribute_body(
|
||||
{"key": "level_offset", "value": -1}, "test"
|
||||
)
|
||||
assert effective_from is None
|
||||
assert note is None
|
||||
|
||||
def test_allows_any_json_value(self) -> None:
|
||||
for value in (0, 2.5, "text", True, [1, 2], {"nested": True}):
|
||||
_, parsed, _, _ = _validate_attribute_body({"key": "k", "value": value}, "test")
|
||||
assert parsed == value
|
||||
|
||||
def test_rejects_missing_key(self) -> None:
|
||||
with pytest.raises(ValueError, match='"key" must be a non-empty string'):
|
||||
_validate_attribute_body({"value": 1}, "test")
|
||||
|
||||
def test_rejects_missing_or_null_value(self) -> None:
|
||||
with pytest.raises(ValueError, match='"value" must carry a JSON value'):
|
||||
_validate_attribute_body({"key": "k"}, "test")
|
||||
with pytest.raises(ValueError, match='"value" must carry a JSON value'):
|
||||
_validate_attribute_body({"key": "k", "value": None}, "test")
|
||||
|
||||
def test_rejects_bad_effective_from(self) -> None:
|
||||
with pytest.raises(ValueError, match="not a valid ISO 8601"):
|
||||
_validate_attribute_body(
|
||||
{"key": "k", "value": 1, "effective_from": "not-a-date"}, "test"
|
||||
)
|
||||
|
||||
def test_rejects_non_string_note(self) -> None:
|
||||
with pytest.raises(ValueError, match='"note" must be a string'):
|
||||
_validate_attribute_body({"key": "k", "value": 1, "note": 42}, "test")
|
||||
|
||||
|
||||
class FakeTokenStore(TokenStore):
|
||||
"""In-memory token store: no database, immediate verification."""
|
||||
|
||||
@@ -409,6 +463,71 @@ class FakeImeiMapStore(ImeiMapStore):
|
||||
self._mapping = {imei: dict(doc) for imei, doc in mapping.items()}
|
||||
|
||||
|
||||
class FakeImeiAttributesStore(ImeiAttributesStore):
|
||||
"""In-memory device-attributes timeline store: no database."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("postgresql://unused")
|
||||
# {imei: {key: [(effective_from, value, note), ...]}} newest last
|
||||
self._timeline: dict[str, dict[str, list[tuple[datetime, Any, str | None]]]] = {}
|
||||
self.appended: tuple[str, str, Any, datetime | None, str | None] | None = None
|
||||
self.deleted: tuple[str, str] | None = None
|
||||
|
||||
def history(self, imei: str) -> dict[str, list[dict[str, Any]]]:
|
||||
result: dict[str, list[dict[str, Any]]] = {}
|
||||
for key, versions in self._timeline.get(imei, {}).items():
|
||||
result[key] = [
|
||||
{"value": value, "effective_from": effective_from, "note": note}
|
||||
for effective_from, value, note in sorted(versions)
|
||||
]
|
||||
return result
|
||||
|
||||
def resolved(self, imei: str, at: datetime | None = None) -> dict[str, Any]:
|
||||
import datetime as dt_mod
|
||||
|
||||
when = at if at is not None else dt_mod.datetime.now(dt_mod.timezone.utc)
|
||||
resolved: dict[str, Any] = {}
|
||||
for key, versions in self._timeline.get(imei, {}).items():
|
||||
applicable = [
|
||||
(effective_from, value)
|
||||
for effective_from, value, _note in versions
|
||||
if effective_from <= when
|
||||
]
|
||||
if applicable:
|
||||
resolved[key] = max(applicable)[1]
|
||||
return resolved
|
||||
|
||||
def append(
|
||||
self,
|
||||
imei: str,
|
||||
key: str,
|
||||
value: Any,
|
||||
effective_from: datetime | None = None,
|
||||
note: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
import datetime as dt_mod
|
||||
|
||||
when = effective_from if effective_from is not None else dt_mod.datetime.now(dt_mod.timezone.utc)
|
||||
self.appended = (imei, key, value, effective_from, note)
|
||||
versions = self._timeline.setdefault(imei, {}).setdefault(key, [])
|
||||
# replace-on-conflict: same (imei, key, effective_from) edits in place
|
||||
versions = [v for v in versions if v[0] != when]
|
||||
versions.append((when, value, note))
|
||||
self._timeline[imei][key] = versions
|
||||
return {"key": key, "value": value, "effective_from": when, "note": note}
|
||||
|
||||
def delete_latest(self, imei: str, key: str) -> bool:
|
||||
self.deleted = (imei, key)
|
||||
versions = self._timeline.get(imei, {}).get(key)
|
||||
if not versions:
|
||||
return False
|
||||
newest = max(effective_from for effective_from, _value, _note in versions)
|
||||
self._timeline[imei][key] = [
|
||||
v for v in versions if v[0] != newest
|
||||
]
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app_module(monkeypatch):
|
||||
"""Import api.app with a fake environment (create_app runs at import
|
||||
@@ -433,13 +552,17 @@ def app_module(monkeypatch):
|
||||
|
||||
@pytest.fixture()
|
||||
def api(app_module):
|
||||
"""ASGI app wired to in-memory fakes; yields (app, token_store, imei_store)."""
|
||||
"""ASGI app wired to in-memory fakes; yields
|
||||
(app, token_store, imei_store, attributes_store)."""
|
||||
tokens = FakeTokenStore({"test-app": "secret-abc"})
|
||||
imeis = FakeImeiMapStore(
|
||||
{"350123456789012": {"device": "Greenhouse sensor A"}}
|
||||
)
|
||||
app = app_module.create_app(token_store=tokens, imei_store=imeis)
|
||||
return app, tokens, imeis
|
||||
attributes = FakeImeiAttributesStore()
|
||||
app = app_module.create_app(
|
||||
token_store=tokens, imei_store=imeis, attributes_store=attributes
|
||||
)
|
||||
return app, tokens, imeis, attributes
|
||||
|
||||
|
||||
class TestApiAuth:
|
||||
@@ -529,7 +652,7 @@ class TestConfigEndpoints:
|
||||
def client_and_stores(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, tokens, imeis = api
|
||||
app, tokens, imeis, _attributes = api
|
||||
return TestClient(app), tokens, imeis
|
||||
|
||||
def test_get_imei_map(self, client_and_stores) -> None:
|
||||
@@ -634,7 +757,7 @@ class TestDeviceEndpoints:
|
||||
def client_and_store(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _, imeis = api
|
||||
app, _, imeis, _attributes = api
|
||||
return TestClient(app), imeis
|
||||
|
||||
def test_list_devices(self, client_and_store) -> None:
|
||||
@@ -744,7 +867,7 @@ class TestTokenEndpoints:
|
||||
def client_and_store(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, tokens, _ = api
|
||||
app, tokens, _, _attributes = api
|
||||
return TestClient(app), tokens
|
||||
|
||||
def test_list_tokens_masks_secrets(self, client_and_store) -> None:
|
||||
@@ -790,4 +913,178 @@ class TestTokenEndpoints:
|
||||
client, _ = client_and_store
|
||||
assert client.get("/tokens").status_code == 401
|
||||
assert client.put("/tokens/x", json={"token": "y"}).status_code == 401
|
||||
assert client.delete("/tokens/x").status_code == 401
|
||||
assert client.delete("/tokens/x").status_code == 401
|
||||
|
||||
|
||||
class TestDeviceAttributeEndpoints:
|
||||
"""GET/PUT/DELETE /devices/{imei}/attributes — the time-varying
|
||||
attribute timeline (append-only; resolved as of a point in time)."""
|
||||
|
||||
HDRS = {"X-App-Token": "secret-abc"}
|
||||
|
||||
@pytest.fixture()
|
||||
def client_and_store(self, api):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _tokens, _imeis, attributes = api
|
||||
return TestClient(app), attributes
|
||||
|
||||
def test_endpoints_require_token(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
assert client.get("/devices/350123456789012/attributes").status_code == 401
|
||||
assert client.put(
|
||||
"/devices/350123456789012/attributes", json={"key": "k", "value": 1}
|
||||
).status_code == 401
|
||||
assert client.delete("/devices/350123456789012/attributes/k").status_code == 401
|
||||
|
||||
def test_rejects_non_imei_url(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
assert client.get("/devices/123/attributes", headers=self.HDRS).status_code == 422
|
||||
assert client.put(
|
||||
"/devices/123/attributes", json={"key": "k", "value": 1}, headers=self.HDRS
|
||||
).status_code == 422
|
||||
|
||||
def test_append_and_resolve_now(self, client_and_store) -> None:
|
||||
client, attributes = client_and_store
|
||||
r = client.put(
|
||||
"/devices/350123456789012/attributes",
|
||||
json={"key": "temperature_offset", "value": 1.5},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["imei"] == "350123456789012"
|
||||
assert body["key"] == "temperature_offset"
|
||||
assert body["value"] == 1.5
|
||||
assert body["effective_from"] # server-side now() default
|
||||
assert attributes.appended == ("350123456789012", "temperature_offset", 1.5, None, None)
|
||||
|
||||
r = client.get("/devices/350123456789012/attributes", headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"imei": "350123456789012", "attributes": {"temperature_offset": 1.5}}
|
||||
|
||||
def test_append_backdated_and_resolve_at(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
# backdated calibration change discovered after a sensor swap
|
||||
r = client.put(
|
||||
"/devices/350123456789012/attributes",
|
||||
json={
|
||||
"key": "temperature_offset",
|
||||
"value": 2.0,
|
||||
"effective_from": "2026-08-15T00:00:00Z",
|
||||
"note": "after sensor swap",
|
||||
},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["effective_from"] == "2026-08-15T00:00:00+00:00"
|
||||
assert r.json()["note"] == "after sensor swap"
|
||||
|
||||
# before the backdated change: not in effect yet
|
||||
r = client.get(
|
||||
"/devices/350123456789012/attributes",
|
||||
params={"at": "2026-08-01T00:00:00Z"},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["attributes"] == {}
|
||||
assert r.json()["at"] == "2026-08-01T00:00:00+00:00"
|
||||
|
||||
# after it: the backdated value is in effect
|
||||
r = client.get(
|
||||
"/devices/350123456789012/attributes",
|
||||
params={"at": "2026-09-01T00:00:00Z"},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.json()["attributes"] == {"temperature_offset": 2.0}
|
||||
|
||||
def test_multiple_versions_resolve_to_latest_in_effect(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
for value, effective in ((1.0, "2026-08-01T00:00:00Z"), (2.0, "2026-08-15T00:00:00Z")):
|
||||
r = client.put(
|
||||
"/devices/350123456789012/attributes",
|
||||
json={"key": "level_offset", "value": value, "effective_from": effective},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# midpoint resolves to the older version, after to the newer
|
||||
r = client.get(
|
||||
"/devices/350123456789012/attributes",
|
||||
params={"at": "2026-08-10T00:00:00Z"},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.json()["attributes"] == {"level_offset": 1.0}
|
||||
r = client.get(
|
||||
"/devices/350123456789012/attributes",
|
||||
params={"at": "2026-08-20T00:00:00Z"},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.json()["attributes"] == {"level_offset": 2.0}
|
||||
|
||||
def test_history_lists_all_versions_oldest_first(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
for value, effective in ((2.0, "2026-08-15T00:00:00Z"), (1.0, "2026-08-01T00:00:00Z")):
|
||||
client.put(
|
||||
"/devices/350123456789012/attributes",
|
||||
json={"key": "level_offset", "value": value, "effective_from": effective},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
r = client.get("/devices/350123456789012/attributes/history", headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"imei": "350123456789012",
|
||||
"attributes": {
|
||||
"level_offset": [
|
||||
{"value": 1.0, "effective_from": "2026-08-01T00:00:00+00:00", "note": None},
|
||||
{"value": 2.0, "effective_from": "2026-08-15T00:00:00+00:00", "note": None},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
def test_append_rejects_bad_bodies(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
for body in (
|
||||
{},
|
||||
{"value": 1}, # missing key
|
||||
{"key": "", "value": 1}, # empty key
|
||||
{"key": "k"}, # missing value
|
||||
{"key": "k", "value": None}, # null value
|
||||
{"key": "k", "value": 1, "effective_from": "yesterday"}, # bad time
|
||||
{"key": "k", "value": 1, "note": 5}, # note must be a string
|
||||
):
|
||||
r = client.put(
|
||||
"/devices/350123456789012/attributes", json=body, headers=self.HDRS
|
||||
)
|
||||
assert r.status_code == 422, body
|
||||
|
||||
def test_post_is_alias_for_put(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.post(
|
||||
"/devices/350123456789012/attributes",
|
||||
json={"key": "k", "value": 1},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_delete_removes_only_latest_version(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
for value, effective in ((1.0, "2026-08-01T00:00:00Z"), (2.0, "2026-08-15T00:00:00Z")):
|
||||
client.put(
|
||||
"/devices/350123456789012/attributes",
|
||||
json={"key": "level_offset", "value": value, "effective_from": effective},
|
||||
headers=self.HDRS,
|
||||
)
|
||||
r = client.delete("/devices/350123456789012/attributes/level_offset", headers=self.HDRS)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"status": "deleted", "imei": "350123456789012", "key": "level_offset"}
|
||||
# the earlier version remains in effect
|
||||
r = client.get("/devices/350123456789012/attributes", headers=self.HDRS)
|
||||
assert r.json()["attributes"] == {"level_offset": 1.0}
|
||||
|
||||
def test_delete_unknown_key_404(self, client_and_store) -> None:
|
||||
client, _ = client_and_store
|
||||
r = client.delete(
|
||||
"/devices/350123456789012/attributes/does-not-exist", headers=self.HDRS
|
||||
)
|
||||
assert r.status_code == 404
|
||||
Reference in New Issue
Block a user