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()) == 6 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