Files
pigway-frame-flow/tests/test_api.py
T

445 lines
15 KiB
Python

import hashlib
import json
from io import BytesIO
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from pigway_frame_flow.api.app import create_app
from pigway_frame_flow.settings import Settings
from pigway_frame_flow.storage.database import encode, now_iso
def test_health_ready(client):
assert client.get("/health").status_code == 200
assert client.get("/ready").status_code == 200
assert all(client.get("/ready").json()["checks"].values())
def test_without_external_project(client):
assert client.get("/").status_code == 200
assert client.get("/api/v1/devices").status_code == 200
assert len(client.get("/api/v1/templates").json()) == 9
assert client.get("/api/v1/jobs").status_code == 200
def test_auth(client):
assert (
client.get("/api/v1/devices", headers={"Authorization": ""}).status_code == 401
)
r = client.get("/api/v1/settings", headers={"Authorization": "Bearer wrong"})
assert r.status_code == 401
assert set(r.json()["error"]) == {"code", "message", "request_id", "details"}
def test_scope(client, service):
service.db.execute(
"INSERT INTO clients VALUES(?,?,?,?,?,?,?)",
(
"reader",
hashlib.sha256(b"reader").hexdigest(),
"Reader",
encode(["devices:read"]),
now_iso(),
None,
1,
),
)
assert (
client.get(
"/api/v1/devices", headers={"Authorization": "Bearer reader"}
).status_code
== 200
)
assert (
client.post(
"/api/v1/display",
headers={"Authorization": "Bearer reader"},
json={"content": {"type": "template", "template": "quote"}},
).status_code
== 403
)
def test_device_schema(client):
device = client.get("/api/v1/devices/living-room").json()
assert device["id"] == "living-room"
assert device["display"]["width"] == 1200
assert device["capabilities"]["color"] is True
assert device["slideshow"]["mode"] == "queue"
assert device["slideshow"]["members_available"] is False
assert "nickname" not in device
assert "api_key" not in json.dumps(device)
def test_all_cloud_capabilities(client, service):
response = client.post(
"/api/v1/display", json={"content": {"type": "template", "template": "quote"}}
)
assert response.status_code == 202
id = response.json()["operation_id"]
service.process_one()
service.sync()
assert client.get("/api/v1/operations/" + id).json()["status"] == "COMPLETED"
images = client.get("/api/v1/devices/living-room/images").json()
assert len(images) == 2
assert client.get(images[0]["url"] + "?thumbnail=true").status_code == 200
name = images[1]["name"]
named = client.post(
"/api/v1/display", json={"content": {"type": "image", "image_name": name}}
)
assert named.status_code == 202
service.process_one()
service.sync()
assert service.registry.get("living-room").get_current_image() == 1
def test_delete_current_confirmation(client):
assert client.delete("/api/v1/devices/living-room/images/1").status_code == 409
def test_preview_local_upload(client, service):
out = BytesIO()
Image.new("RGB", (100, 200), "blue").save(out, "PNG")
preview = client.post(
"/api/v1/preview",
files={"file": ("sample.png", out.getvalue(), "image/png")},
data={"fit": "contain", "profile": "grayscale"},
)
assert preview.status_code == 200
asset = preview.json()
assert client.get(asset["url"]).status_code == 200
r = client.post(
"/api/v1/devices/living-room/upload",
json={"type": "image", "asset_id": asset["asset_id"]},
)
assert r.status_code == 202
service.process_one()
assert service.registry.get("living-room").get_current_image() == 1
assert service.registry.get("living-room").client.uploads == 0
@pytest.mark.parametrize(
"content",
[{"type": "html", "data": "hello"}, {"type": "markdown", "data": "hello"}],
)
def test_unsupported(client, content):
r = client.post("/api/v1/display", json={"content": content})
assert r.status_code == 422
assert r.json()["error"]["code"] == "UNSUPPORTED_CAPABILITY"
def test_idempotency_api(client, service):
p = {"content": {"type": "template", "template": "quote"}}
a = client.post("/api/v1/display", json=p, headers={"Idempotency-Key": "repeat"})
b = client.post("/api/v1/display", json=p, headers={"Idempotency-Key": "repeat"})
assert a.json() == b.json()
service.process_one()
assert service.registry.get("living-room").client.uploads == 1
def test_request_id(client):
r = client.get(
"/api/v1/devices/no-device", headers={"X-Request-ID": "external-123"}
)
assert r.status_code == 404
assert r.headers["X-Request-ID"] == "external-123"
assert r.json()["error"]["request_id"] == "external-123"
def test_secret_never_returned(client, service):
assert "test-frame-token" not in client.get("/api/v1/settings").text
data = client.post(
"/api/v1/display",
json={
"content": {
"type": "template",
"template": "quote",
"data": {"title": "test-frame-token"},
}
},
)
row = service.db.one(
"SELECT payload FROM operations WHERE id=?", (data.json()["operation_id"],)
)
assert "test-frame-token" not in row["payload"]
assert "test-frame-token" not in str(service.settings)
def test_cors_default(client):
response = client.options(
"/api/v1/display",
headers={
"Origin": "https://external.example",
"Access-Control-Request-Method": "POST",
},
)
assert "access-control-allow-origin" not in response.headers
def test_missing_token_not_ready(tmp_path):
app = create_app(
Settings(
data_dir=tmp_path / "data", config_dir=tmp_path / "config", api_token=""
),
False,
)
with TestClient(app) as c:
assert c.get("/ready").status_code == 503
assert (
c.post(
"/api/v1/display",
json={"content": {"type": "template", "template": "quote"}},
).status_code
== 401
)
def test_live_read_only(tmp_path):
app = create_app(
Settings(
data_dir=tmp_path / "data",
config_dir=tmp_path / "config",
api_token="test",
api_key="fake-device-key",
mock=False,
write_enabled=False,
),
False,
)
with TestClient(app) as c:
c.headers["Authorization"] = "Bearer test"
assert c.get("/ready").status_code == 200
assert (
c.post(
"/api/v1/display",
json={"content": {"type": "template", "template": "quote"}},
).status_code
== 403
)
assert c.delete("/api/v1/devices/living-room/images/1").status_code == 403
assert c.post("/api/v1/devices/living-room/restore").status_code == 403
def test_multidevice(tmp_path):
config = tmp_path / "config"
config.mkdir()
(config / "devices.yml").write_text(
"devices:\n living-room:\n driver: kokonna\n office:\n driver: kokonna\n"
)
app = create_app(
Settings(data_dir=tmp_path / "data", config_dir=config, api_token="test"), False
)
with TestClient(app) as c:
c.headers["Authorization"] = "Bearer test"
assert len(c.get("/api/v1/devices").json()) == 2
assert (
c.post(
"/api/v1/devices/office/display",
json={"content": {"type": "image", "image_id": 1}},
).status_code
== 202
)
app.state.service.process_one()
assert app.state.service.registry.get("living-room").client.displays == []
assert app.state.service.registry.get("office").client.displays == [1]
def test_job_crud(client):
p = {
"name": "Morning",
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "template", "template": "daily-dashboard"},
}
result = client.post("/api/v1/jobs", json=p)
assert result.status_code == 201
id = result.json()["id"]
p["enabled"] = False
assert client.put("/api/v1/jobs/" + id, json=p).json()["enabled"] is False
assert client.delete("/api/v1/jobs/" + id).status_code == 204
def test_contract(client, app):
schema = client.get("/openapi.json").json()
expected = json.loads((Path(__file__).parent / "contract.json").read_text())
actual = {
path: {method: sorted(spec["responses"]) for method, spec in methods.items()}
for path, methods in schema["paths"].items()
}
assert actual == expected
assert schema["components"]["securitySchemes"]["HTTPBearer"]["scheme"] == "bearer"
assert "Device" in schema["components"]["schemas"]
assert schema["components"]["schemas"]["OperationStatus"]["enum"] == [
"QUEUED",
"RENDERING",
"UPLOADING",
"SYNCING",
"COMPLETED",
"FAILED",
"UNKNOWN",
]
assert client.get("/docs").status_code == 200
def test_core_has_no_client_special_case():
for path in Path("pigway_frame_flow").rglob("*.py"):
assert "ticktar" not in path.read_text().lower()
assert (
"KokonnaClient" not in Path("pigway_frame_flow/display/service.py").read_text()
)
def test_ui_languages(client):
assert client.get("/ui/app.js").status_code == 200
i18n = client.get("/ui/i18n.js").text
assert "zh:" in i18n and "en:" in i18n
assert "data-theme=dark" in client.get("/ui/style.css").text
def test_local_asset_deletion(client, service):
asset = service.assets.put(service.renderer.render("quote", {}), "quote.jpg")
assert client.delete("/api/v1/assets/" + asset["asset_id"]).status_code == 204
assert client.get(asset["url"]).status_code == 404
def test_asset_reference_prevents_delete(client, service):
asset = service.assets.put(service.renderer.render("quote", {}), "photo.jpg")
service.submit(
"display",
{"content": {"type": "image", "asset_id": asset["asset_id"]}},
"living-room",
"admin",
"reference",
)
assert client.delete("/api/v1/assets/" + asset["asset_id"]).status_code == 409
def test_unknown_same_idempotency_key_returns_original(client, service):
from pigway_frame_flow.errors import ErrorCode, FlowError
payload = {"content": {"type": "template", "template": "quote"}}
def fail(*args):
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Write outcome unknown", 502)
service.registry.get("living-room").upload = fail
original = client.post(
"/api/v1/display", json=payload, headers={"Idempotency-Key": "timeout-test"}
)
service.process_one()
assert service.operation(original.json()["operation_id"])["status"] == "UNKNOWN"
retry = client.post(
"/api/v1/display", json=payload, headers={"Idempotency-Key": "timeout-test"}
)
assert retry.status_code == 202
assert retry.json() == original.json()
fresh = client.post(
"/api/v1/display", json=payload, headers={"Idempotency-Key": "new-key"}
)
assert fresh.status_code == 409
def test_schema_contract(client):
schemas = client.get("/openapi.json").json()["components"]["schemas"]
expected = json.loads((Path(__file__).parent / "schema-contract.json").read_text())
assert {key: schemas[key] for key in expected} == expected
def test_preview_is_draft_until_saved(client, service):
out = BytesIO()
Image.new("RGB", (50, 50), "red").save(out, "PNG")
preview = client.post(
"/api/v1/preview", files={"file": ("draft.png", out.getvalue(), "image/png")}
).json()
assert preview["name"] == "draft.jpg"
assert not any(
x["asset_id"] == preview["asset_id"]
for x in client.get("/api/v1/assets").json()
)
saved = client.post(
"/api/v1/devices/living-room/upload",
json={"type": "image", "asset_id": preview["asset_id"]},
)
assert saved.status_code == 202
service.process_one()
assert any(
x["asset_id"] == preview["asset_id"]
for x in client.get("/api/v1/assets").json()
)
assert service.registry.get("living-room").client.uploads == 0
def test_restore_availability(client, service):
assert client.get("/api/v1/devices/living-room").json()["can_restore"] is False
client.post(
"/api/v1/display", json={"content": {"type": "template", "template": "quote"}}
)
service.process_one()
assert client.get("/api/v1/devices/living-room").json()["can_restore"] is True
def test_template_configuration_reload(client, service):
assert (
client.put(
"/api/v1/templates/quote/config", json={"data": {"quote": "Saved words"}}
).status_code
== 200
)
assert (
client.get("/api/v1/templates/quote").json()["defaults"]["quote"]
== "Saved words"
)
def test_docs_offline_resources(client):
html = client.get("/docs").text
assert "/ui/swagger-ui-bundle.js" in html
assert "cdn.jsdelivr" not in html
assert client.get("/ui/swagger-ui-bundle.js").status_code == 200
assert client.get("/ui/swagger-ui.css").status_code == 200
def test_scoped_client_operation_isolation(client, service):
token = "restricted-client-fixture"
service.db.execute(
"INSERT INTO clients VALUES(?,?,?,?,?,?,?)",
(
"reader",
hashlib.sha256(token.encode()).hexdigest(),
"Reader",
encode(["devices:read", "history:read"]),
now_iso(),
None,
1,
),
)
own = service.submit("render", {}, "living-room", "reader", "own-request")
other = service.submit("render", {}, "living-room", "scheduler", "other-request")
client.headers["Authorization"] = "Bearer " + token
assert client.get("/api/v1/operations/" + own).status_code == 200
assert client.get("/api/v1/operations/" + other).status_code == 403
assert [op["operation_id"] for op in client.get("/api/v1/operations").json()] == [
own
]
def test_settings_exposes_storage_display_name(client, service):
service.settings.storage_name = "Family NAS"
result = client.get("/api/v1/settings").json()
assert result["storage_name"] == "Family NAS"
destinations = {d["id"]: d for d in result["gallery_destinations"]}
assert set(destinations) == {"cloud", "local"}
assert destinations["cloud"]["name"] == "KoKonna"
assert destinations["local"]["name"] == "Family NAS"
assert all(d["connected"] and d["supports_upload"] for d in destinations.values())
assert "test-frame-token" not in str(result)
def test_storage_display_name_environment(monkeypatch):
monkeypatch.setenv("FRAME_FLOW_STORAGE_NAME", " Family NAS ")
assert Settings().storage_name == "Family NAS"
monkeypatch.setenv("FRAME_FLOW_STORAGE_NAME", " ")
assert Settings().storage_name == "NAS"