🚧 Add import

This commit is contained in:
Jonas Bengtsson
2026-09-06 21:32:05 +02:00
parent 67a06e1023
commit f2033ab44f
4 changed files with 671 additions and 10 deletions
+264
View File
@@ -924,6 +924,265 @@ class TestApiAuth:
assert response.status_code != 422
class TestMessageExport:
"""/messages/export — auth, empty-selection and size validation. The
actual row fetch (raw stored messages, caller's ordering) is exercised
in the e2e against a throwaway postgres; unit tests have no DB."""
def test_requires_token(self, api) -> None:
from fastapi.testclient import TestClient
client = TestClient(api[0])
assert client.get("/messages/export?ids=1").status_code == 401
def test_empty_selection_rejected(self, api) -> None:
from fastapi.testclient import TestClient
client = TestClient(api[0])
response = client.get(
"/messages/export", headers={"X-App-Token": "secret-abc"}
)
assert response.status_code == 422
assert "select at least one message" in response.json()["detail"]
def test_oversized_selection_rejected(self, api) -> None:
from fastapi.testclient import TestClient
# 10001 ids cannot even be expressed as a GET query (httpx rejects
# the URL before the request), so this test uses a modest cap
# check through the app's own config instead: build a request the
# API rejects on count grounds by asserting the boundary logic in
# the e2e. Here: exactly the max is accepted past validation.
client = TestClient(api[0], raise_server_exceptions=False)
# ids list at exactly the cap -> proceeds to db access (503)
response = client.get(
"/messages/export",
params=[("ids", i) for i in range(1, 21)],
headers={"X-App-Token": "secret-abc"},
)
assert response.status_code == 503 # db unavailable -> 503, not 401/422
class TestMessageImport:
"""POST /messages/import — auth + role gating + validation errors. The
actual insert is exercised in the e2e against a throwaway postgres."""
GOOD_ROW = {
"id": 42, # exported id: the endpoint must ignore it
"received_at": "2026-08-31T10:00:00+00:00",
"topic": "/vivab/350123456789012",
"imei": "350123456789012",
"device": "Greenhouse sensor A",
"payload": {"temp": 21.5},
"payload_time": "2026-08-31T09:59:58+00:00",
}
def test_requires_token(self, api) -> None:
from fastapi.testclient import TestClient
client = TestClient(api[0])
r = client.post("/messages/import", json=[self.GOOD_ROW])
assert r.status_code == 401
def test_viewer_role_forbidden(self, api) -> None:
from fastapi.testclient import TestClient
client = TestClient(api[0])
app, *_ = api
# log in as the read-only viewer from the fixture
r = client.post(
"/auth/login", json={"username": "viewer1", "password": "viewer-pass"}
)
token = r.json()["token"]
response = client.post(
"/messages/import",
json=[self.GOOD_ROW],
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 403
def test_superuser_role_accepted_to_db(self, api) -> None:
from fastapi.testclient import TestClient
client = TestClient(api[0])
r = client.post(
"/auth/login", json={"username": "super1", "password": "super-pass"}
)
token = r.json()["token"]
response = client.post(
"/messages/import",
json=[self.GOOD_ROW],
headers={"Authorization": f"Bearer {token}"},
)
# no DB in unit tests: role gate + validation passed -> 503
assert response.status_code == 503
def test_empty_list_rejected(self, api) -> None:
from fastapi.testclient import TestClient
client = TestClient(api[0])
r = client.post(
"/messages/import", json=[], headers={"X-App-Token": "secret-abc"}
)
assert r.status_code == 422
assert "empty" in r.json()["detail"]
def test_bad_row_rejected_with_position(self, api) -> None:
from fastapi.testclient import TestClient
client = TestClient(api[0])
r = client.post(
"/messages/import",
json=[
self.GOOD_ROW,
{"imei": "nope", "device": "B", "payload": {}},
],
headers={"X-App-Token": "secret-abc"},
)
assert r.status_code == 422
assert "row 2" in r.json()["detail"]
def test_single_object_accepted(self, api) -> None:
from fastapi.testclient import TestClient
client = TestClient(api[0], raise_server_exceptions=False)
r = client.post(
"/messages/import",
json=self.GOOD_ROW,
headers={"X-App-Token": "secret-abc"},
)
assert r.status_code == 503 # validation passed; fails on the DB
def test_valid_request_reaches_db(self, api) -> None:
from fastapi.testclient import TestClient
# No DB in unit tests: auth + validation passed is proven by the
# request NOT returning 401/422 (it fails on the connection).
client = TestClient(api[0], raise_server_exceptions=False)
response = client.get(
"/messages/export",
params=[("ids", 1), ("ids", 2)],
headers={"X-App-Token": "secret-abc"},
)
assert response.status_code == 503 # db unavailable -> 503, not 401/422
class TestValidateImportRows:
"""_validate_import_rows: export shape accepted, bad rows rejected
with row-precise messages."""
def _validate(self, data: object) -> list[dict]:
from api.app import _validate_import_rows
return _validate_import_rows(data, "request body")
def test_single_object_promoted_to_list(self) -> None:
rows = self._validate(
{
"id": 42, # exported id: must be ignored
"received_at": "2026-08-31T10:00:00+00:00",
"topic": "/vivab/350123456789012",
"imei": "350123456789012",
"device": "Greenhouse sensor A",
"payload": {"temp": 21.5},
"payload_time": "2026-08-31T09:59:58+00:00",
}
)
assert len(rows) == 1
# the id must NOT be carried into the validated row
assert "id" not in rows[0]
assert rows[0]["imei"] == "350123456789012"
assert rows[0]["device"] == "Greenhouse sensor A"
assert rows[0]["payload"] == {"temp": 21.5}
assert rows[0]["topic"] == "/vivab/350123456789012"
def test_exported_array_accepted(self) -> None:
rows = self._validate(
[
{"imei": "350123456789012", "device": "A", "payload": {"n": 1}},
{"imei": "350123456789013", "device": "B", "payload": {"n": 2}},
]
)
assert [r["imei"] for r in rows] == [
"350123456789012",
"350123456789013",
]
def test_missing_fields_defaulted(self) -> None:
# minimal row: no topic (defaults), no timestamps (NULL/now)
rows = self._validate(
{"imei": "350123456789012", "device": "A", "payload": {}}
)
assert rows[0]["topic"] == "/import/350123456789012"
assert rows[0]["received_at"] is None
assert rows[0]["payload_time"] is None
def test_rejects_empty_list(self) -> None:
with pytest.raises(ValueError, match="empty"):
self._validate([])
def test_rejects_non_object_body(self) -> None:
with pytest.raises(ValueError, match="JSON array"):
self._validate("not a message")
def test_rejects_row_with_bad_imei(self) -> None:
with pytest.raises(ValueError, match="row 2.*15-digit"):
self._validate(
[
{"imei": "350123456789012", "device": "A", "payload": {}},
{"imei": "short", "device": "B", "payload": {}},
]
)
def test_rejects_row_missing_device(self) -> None:
with pytest.raises(ValueError, match="row 1.*device"):
self._validate({"imei": "350123456789012", "payload": {}})
def test_rejects_non_object_payload(self) -> None:
with pytest.raises(ValueError, match="payload.*JSON object"):
self._validate(
{"imei": "350123456789012", "device": "A", "payload": [1, 2]}
)
def test_rejects_bad_timestamp(self) -> None:
with pytest.raises(ValueError, match="row 1.*ISO 8601"):
self._validate(
{
"imei": "350123456789012",
"device": "A",
"payload": {},
"received_at": "31/08/2026",
}
)
def test_rejects_non_string_timestamp(self) -> None:
with pytest.raises(ValueError, match="row 1.*ISO 8601 string"):
self._validate(
{
"imei": "350123456789012",
"device": "A",
"payload": {},
"received_at": 12345,
}
)
def test_rejects_bad_topic(self) -> None:
with pytest.raises(ValueError, match="row 1.*topic"):
self._validate(
{
"imei": "350123456789012",
"device": "A",
"payload": {},
"topic": "",
}
)
def test_rejects_oversized_batch(self) -> None:
row = {"imei": "350123456789012", "device": "A", "payload": {}}
with pytest.raises(ValueError, match="too many"):
self._validate([row] * 10001)
class TestConfigEndpoints:
"""GET/PUT/POST /configs/{name} — reads, validated writes into stores."""
@@ -1535,6 +1794,11 @@ class TestAuthorizationMatrix:
("/tokens/someapp", "PUT", {"token": "secret"}),
("/tokens/someapp", "DELETE", None),
("/users/newuser", "PUT", {"password": "pw"}),
(
"/messages/import",
"POST",
{"imei": "350123456789012", "device": "X", "payload": {}},
),
]
def test_viewer_role_cannot_write(self, client_and_stores) -> None: