Build Pigway Frame Flow with bilingual UI, complete KoKonna driver and safe automation

This commit is contained in:
Pigway
2026-10-02 00:03:48 +08:00
commit 6652590b84
79 changed files with 13773 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import pytest
from fastapi.testclient import TestClient
from pigway_frame_flow.api.app import create_app
from pigway_frame_flow.settings import Settings
@pytest.fixture
def settings(tmp_path):
return Settings(
data_dir=tmp_path / "data",
config_dir=tmp_path / "config",
api_token="test-frame-token",
mock=True,
)
@pytest.fixture
def app(settings):
return create_app(settings, start_worker=False)
@pytest.fixture
def client(app):
with TestClient(app) as c:
c.headers["Authorization"] = "Bearer test-frame-token"
yield c
@pytest.fixture
def service(app):
return app.state.service
+536
View File
@@ -0,0 +1,536 @@
{
"/health": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/ready": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/version": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/images": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/images/{image_id}/file": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/display": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/display": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/display": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/render": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/upload": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/preview": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/assets": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/assets/{asset_id}": {
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/images/{image_id}": {
"delete": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/restore": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/operations": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/operations/{operation_id}": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/operations/{operation_id}/reconcile": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/templates": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/templates/{template_id}": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/templates/{template_id}/config": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/jobs": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"201",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/jobs/{job_id}": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/playlists": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"201",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/playlists/{playlist_id}": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/history": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/settings": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
}
}
+751
View File
@@ -0,0 +1,751 @@
{
"Device": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
},
"driver": {
"type": "string",
"const": "kokonna",
"title": "Driver",
"default": "kokonna"
},
"status": {
"type": "string",
"enum": [
"online",
"offline"
],
"title": "Status"
},
"model": {
"type": "string",
"title": "Model"
},
"model_code": {
"type": "string",
"title": "Model Code"
},
"firmware": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Firmware"
},
"display": {
"$ref": "#/components/schemas/DisplayGeometry"
},
"power": {
"$ref": "#/components/schemas/Power"
},
"current_content": {
"$ref": "#/components/schemas/CurrentContent"
},
"last_seen_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Last Seen At"
},
"timezone": {
"type": "string",
"title": "Timezone"
},
"capabilities": {
"$ref": "#/components/schemas/Capabilities"
},
"slideshow": {
"additionalProperties": true,
"type": "object",
"title": "Slideshow"
},
"storage_used_bytes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Storage Used Bytes"
},
"model_verified": {
"type": "boolean",
"title": "Model Verified",
"default": false
}
},
"type": "object",
"required": [
"id",
"name",
"status",
"model",
"model_code",
"firmware",
"display",
"power",
"current_content",
"last_seen_at",
"timezone"
],
"title": "Device"
},
"Capabilities": {
"properties": {
"upload": {
"type": "boolean",
"title": "Upload",
"default": true
},
"gallery": {
"type": "boolean",
"title": "Gallery",
"default": true
},
"delete": {
"type": "boolean",
"title": "Delete",
"default": true
},
"switch": {
"type": "boolean",
"title": "Switch",
"default": true
},
"remote_refresh": {
"type": "boolean",
"title": "Remote Refresh",
"default": true
},
"battery": {
"type": "boolean",
"title": "Battery",
"default": true
},
"color": {
"type": "boolean",
"title": "Color",
"default": true
},
"cloud_upload_without_display": {
"type": "boolean",
"title": "Cloud Upload Without Display",
"default": false
}
},
"type": "object",
"title": "Capabilities"
},
"DisplayGeometry": {
"properties": {
"width": {
"type": "integer",
"title": "Width"
},
"height": {
"type": "integer",
"title": "Height"
},
"rotation": {
"type": "integer",
"title": "Rotation"
},
"color_mode": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"type": "object",
"required": [
"width",
"height",
"rotation"
],
"title": "DisplayGeometry"
},
"Power": {
"properties": {
"battery_percent": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Battery Percent"
},
"charging": {
"type": "boolean",
"title": "Charging"
}
},
"type": "object",
"required": [
"battery_percent",
"charging"
],
"title": "Power"
},
"CurrentContent": {
"properties": {
"type": {
"type": "string",
"const": "image",
"title": "Type",
"default": "image"
},
"id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Id"
},
"cover_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Cover Id"
},
"synced": {
"type": "boolean",
"title": "Synced"
}
},
"type": "object",
"required": [
"id",
"cover_id",
"synced"
],
"title": "CurrentContent"
},
"GalleryImage": {
"properties": {
"width": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Width"
},
"height": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Height"
},
"size_bytes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Size Bytes"
},
"media_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Media Type"
},
"image_id": {
"type": "integer",
"title": "Image Id"
},
"name": {
"type": "string",
"title": "Name"
},
"created_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Created At"
},
"current": {
"type": "boolean",
"title": "Current"
},
"url": {
"type": "string",
"title": "Url"
}
},
"type": "object",
"required": [
"image_id",
"name",
"created_at",
"current",
"url"
],
"title": "GalleryImage"
},
"DisplayRequest": {
"properties": {
"device_id": {
"type": "string",
"title": "Device Id",
"default": "living-room"
},
"content": {
"oneOf": [
{
"$ref": "#/components/schemas/ImageContent"
},
{
"$ref": "#/components/schemas/URLContent"
},
{
"$ref": "#/components/schemas/TemplateContent"
},
{
"$ref": "#/components/schemas/FutureContent"
}
],
"title": "Content",
"discriminator": {
"propertyName": "type",
"mapping": {
"html": "#/components/schemas/FutureContent",
"image": "#/components/schemas/ImageContent",
"image_url": "#/components/schemas/URLContent",
"markdown": "#/components/schemas/FutureContent",
"template": "#/components/schemas/TemplateContent"
}
}
},
"options": {
"$ref": "#/components/schemas/Options"
},
"source": {
"$ref": "#/components/schemas/Source"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"content"
],
"title": "DisplayRequest"
},
"ImageContent": {
"properties": {
"type": {
"type": "string",
"const": "image",
"title": "Type"
},
"image_id": {
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Image Id"
},
"image_name": {
"anyOf": [
{
"type": "string",
"maxLength": 120,
"minLength": 1
},
{
"type": "null"
}
],
"title": "Image Name"
},
"asset_id": {
"anyOf": [
{
"type": "string",
"pattern": "^[a-f0-9]{32}$"
},
{
"type": "null"
}
],
"title": "Asset Id"
},
"base64": {
"anyOf": [
{
"type": "string",
"maxLength": 28000000
},
{
"type": "null"
}
],
"title": "Base64"
},
"name": {
"type": "string",
"maxLength": 120,
"minLength": 1,
"title": "Name",
"default": "image.jpg"
},
"fit": {
"$ref": "#/components/schemas/Fit",
"default": "contain"
},
"profile": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"type"
],
"title": "ImageContent"
},
"URLContent": {
"properties": {
"type": {
"type": "string",
"const": "image_url",
"title": "Type"
},
"url": {
"type": "string",
"maxLength": 2048,
"title": "Url"
},
"name": {
"type": "string",
"maxLength": 120,
"title": "Name",
"default": "image.jpg"
},
"fit": {
"$ref": "#/components/schemas/Fit",
"default": "contain"
},
"profile": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"type",
"url"
],
"title": "URLContent"
},
"TemplateContent": {
"properties": {
"type": {
"type": "string",
"const": "template",
"title": "Type"
},
"template": {
"type": "string",
"pattern": "^[a-z0-9-]{1,60}$",
"title": "Template"
},
"data": {
"additionalProperties": true,
"type": "object",
"title": "Data"
},
"profile": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"type",
"template"
],
"title": "TemplateContent"
},
"Options": {
"properties": {
"duration": {
"type": "integer",
"maximum": 604800.0,
"minimum": 0.0,
"title": "Duration",
"default": 0
},
"restore_previous": {
"type": "boolean",
"title": "Restore Previous",
"default": false
},
"priority": {
"type": "integer",
"maximum": 100.0,
"minimum": 0.0,
"title": "Priority",
"default": 50
}
},
"additionalProperties": false,
"type": "object",
"title": "Options"
},
"Accepted": {
"properties": {
"operation_id": {
"type": "string",
"title": "Operation Id"
},
"job_id": {
"type": "string",
"title": "Job Id"
},
"status": {
"type": "string",
"const": "accepted",
"title": "Status",
"default": "accepted"
}
},
"type": "object",
"required": [
"operation_id",
"job_id"
],
"title": "Accepted"
},
"Operation": {
"properties": {
"operation_id": {
"type": "string",
"title": "Operation Id"
},
"status": {
"$ref": "#/components/schemas/OperationStatus"
},
"device_id": {
"type": "string",
"title": "Device Id"
},
"created_at": {
"type": "string",
"title": "Created At"
},
"updated_at": {
"type": "string",
"title": "Updated At"
},
"result": {
"anyOf": [
{
"$ref": "#/components/schemas/OperationResult"
},
{
"type": "null"
}
]
},
"error": {
"anyOf": [
{
"$ref": "#/components/schemas/ErrorBody"
},
{
"type": "null"
}
]
},
"request_id": {
"type": "string",
"title": "Request Id"
},
"client_id": {
"type": "string",
"title": "Client Id"
},
"action": {
"type": "string",
"title": "Action"
}
},
"type": "object",
"required": [
"operation_id",
"status",
"device_id",
"created_at",
"updated_at",
"request_id",
"client_id",
"action"
],
"title": "Operation"
},
"OperationResult": {
"properties": {
"image_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Image Id"
},
"asset_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Asset Id"
},
"rendered_asset_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Rendered Asset Id"
},
"url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Url"
},
"deferred": {
"type": "boolean",
"title": "Deferred",
"default": false
}
},
"type": "object",
"title": "OperationResult"
},
"OperationStatus": {
"type": "string",
"enum": [
"QUEUED",
"RENDERING",
"UPLOADING",
"SYNCING",
"COMPLETED",
"FAILED",
"UNKNOWN"
],
"title": "OperationStatus"
},
"ErrorBody": {
"properties": {
"code": {
"$ref": "#/components/schemas/ErrorCode"
},
"message": {
"type": "string",
"title": "Message"
},
"request_id": {
"type": "string",
"title": "Request Id"
},
"details": {
"additionalProperties": true,
"type": "object",
"title": "Details"
}
},
"type": "object",
"required": [
"code",
"message",
"request_id"
],
"title": "ErrorBody"
},
"ErrorResponse": {
"properties": {
"error": {
"$ref": "#/components/schemas/ErrorBody"
}
},
"type": "object",
"required": [
"error"
],
"title": "ErrorResponse"
}
}
+347
View File
@@ -0,0 +1,347 @@
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
+120
View File
@@ -0,0 +1,120 @@
from unittest.mock import Mock
import pytest
import requests
from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
from pigway_frame_flow.errors import FlowError
def session(body=None, status=200):
s = Mock()
s.request.return_value.status_code = status
s.request.return_value.json.return_value = body or {}
s.request.return_value.content = b"image"
return s
def test_query():
s = session({"nickname": "Test"})
c = KokonnaClient("secret", session=s)
assert c.get_device()["nickname"] == "Test"
kw = s.request.call_args.kwargs
assert kw["headers"]["Authorization"] == "Bearer secret"
assert s.request.call_args.args[:2] == (
"POST",
"https://api.kokonna.art/openapi/device",
)
@pytest.mark.parametrize(
"endpoint,method,args",
[
("/listImages", "list_images", ()),
("/displayImageById", "display", (2,)),
("/deleteImage", "delete", (2,)),
("/displayImageByName", "display_by_name", ("art.jpg",)),
],
)
def test_endpoints(endpoint, method, args):
s = session({"success": True})
c = KokonnaClient("secret", True, s)
getattr(c, method)(*args)
assert s.request.call_args.args[1].endswith(endpoint)
def test_upload_payload(tmp_path):
path = tmp_path / "x.jpg"
path.write_bytes(b"jpeg")
s = session({"id": 10, "counter": 2})
c = KokonnaClient("secret", True, s)
assert c.upload(path, "art.jpg") == 10
assert s.request.call_args.kwargs["json"] == {
"base64": "anBlZw==",
"name": "art.jpg",
}
def test_download_not_leaked():
s = session()
c = KokonnaClient("super-secret", session=s)
assert c.download(12) == b"image"
assert s.request.call_args.kwargs["allow_redirects"] is False
@pytest.mark.parametrize(
"method,args",
[
("upload", (None, "x")),
("display", (1,)),
("delete", (1,)),
("display_by_name", ("x",)),
],
)
def test_write_disabled(method, args):
s = session()
c = KokonnaClient("secret", False, s)
with pytest.raises(FlowError, match="authorization"):
getattr(c, method)(*args)
s.request.assert_not_called()
@pytest.mark.parametrize("write", [False, True])
def test_timeout_redaction_no_retry(write):
s = session()
s.request.side_effect = requests.Timeout("secret https://host/secret")
c = KokonnaClient("secret", True, s)
with pytest.raises(FlowError) as e:
c.display(1) if write else c.get_device()
assert "secret" not in str(e.value)
assert e.value.code == ("WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE")
assert s.request.call_count == 1
@pytest.mark.parametrize("status", [401, 429, 500, 530])
def test_cloud_error_sanitized(status):
s = session({"message": "secret"}, status)
c = KokonnaClient("secret", True, s)
with pytest.raises(FlowError) as e:
c.get_device()
assert "secret" not in str(e.value)
def test_invalid_upload_id(tmp_path):
path = tmp_path / "x.jpg"
path.write_bytes(b"jpeg")
c = KokonnaClient("secret", True, session({"counter": 2}))
with pytest.raises(FlowError) as e:
c.upload(path, "x")
assert e.value.code == "WRITE_RESULT_UNKNOWN"
def test_rate_budget():
s = session({"nickname": "x"})
c = KokonnaClient("secret", session=s)
for _ in range(18):
c.get_device()
with pytest.raises(FlowError) as e:
c.get_device()
assert e.value.code == "RATE_LIMITED"
assert s.request.call_count == 18
+153
View File
@@ -0,0 +1,153 @@
from datetime import UTC, datetime, timedelta
import pytest
from pigway_frame_flow.display.service import DisplayService
from pigway_frame_flow.errors import ErrorCode, FlowError
from pigway_frame_flow.models import DisplayRequest
def submit(service, duration=0, restore=False, priority=50, key=None, content=None):
request = DisplayRequest(
content=content or {"type": "template", "template": "quote", "data": {}},
options={
"duration": duration,
"restore_previous": restore,
"priority": priority,
},
)
return service.submit(
"display",
request.model_dump(mode="json"),
"living-room",
"admin",
"request-test",
key,
)
def expired(service, id):
service.db.execute(
"UPDATE overrides SET expires_at=? WHERE id=?",
((datetime.now(UTC) - timedelta(seconds=1)).isoformat(), id),
)
def test_display_restore(service):
id = submit(service, 60, True)
service.process_one()
service.sync()
assert service.operation(id)["status"] == "COMPLETED"
assert service.registry.get("living-room").get_current_image() == 2
expired(service, id)
service.expire()
service.sync()
assert service.registry.get("living-room").get_current_image() == 1
def test_priority_lower_defers_upload(service):
high = submit(service, 300, True, 90)
service.process_one()
low = submit(service, 60, True, 10)
service.process_one()
assert service.operation(low)["status"] == "QUEUED"
assert service.registry.get("living-room").client.uploads == 1
expired(service, high)
service.expire()
service.process_one()
assert service.registry.get("living-room").client.uploads == 2
def test_nested_overrides(service):
a = submit(service, 300, True, 10)
service.process_one()
b = submit(service, 60, True, 90)
service.process_one()
expired(service, b)
service.expire()
assert service.registry.get("living-room").get_current_image() == 2
expired(service, a)
service.expire()
assert service.registry.get("living-room").get_current_image() == 1
def test_no_retry_unknown(service):
driver = service.registry.get("living-room")
def timeout(*a):
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Write outcome unknown", 502)
driver.upload = timeout
id = submit(service)
service.process_one()
assert service.operation(id)["status"] == "UNKNOWN"
with pytest.raises(FlowError):
submit(service)
assert service.process_one() is False
assert service.reconcile(id)["resolved"] is False
assert service.reconcile(id, True)["resolved"] is True
def test_restart_preserves_override(service):
id = submit(service, 60, True)
service.process_one()
restarted = DisplayService(
service.db, service.settings, service.registry, service.assets, service.renderer
)
expired(restarted, id)
restarted.expire()
assert service.registry.get("living-room").get_current_image() == 1
def test_restart_marks_inflight_unknown(service):
id = submit(service)
service._start_write(id)
DisplayService(
service.db, service.settings, service.registry, service.assets, service.renderer
)
assert service.operation(id)["status"] == "UNKNOWN"
def test_idempotency(service):
first = submit(service, key="repeat")
second = submit(service, key="repeat")
assert first == second
service.process_one()
service.process_one()
assert service.registry.get("living-room").client.uploads == 1
with pytest.raises(FlowError):
submit(service, priority=10, key="repeat")
def test_manual_change_prevents_restore(service):
id = submit(service, 60, True)
service.process_one()
driver = service.registry.get("living-room")
driver.client.display(1)
driver.invalidate()
expired(service, id)
service.expire()
assert driver.get_current_image() == 1
assert not service.db.all("SELECT * FROM overrides")
def test_delete_restore_reference_protected(service):
submit(service, 60, True)
service.process_one()
delete = service.submit(
"delete",
{"image_id": 1, "confirm_current": True},
"living-room",
"admin",
"delete-test",
)
service.process_one()
assert service.operation(delete)["status"] == "FAILED"
assert service.operation(delete)["error"]["code"] == "CONFLICT"
def test_offline(service):
service.registry.get("living-room").client.online = False
id = submit(service)
service.process_one()
assert service.operation(id)["error"]["code"] == "DEVICE_OFFLINE"
+70
View File
@@ -0,0 +1,70 @@
from io import BytesIO
import pytest
from PIL import Image
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.renderers.images import convert
def image_bytes(format="PNG", size=(800, 400)):
out = BytesIO()
Image.new("RGB", size, "red").save(out, format)
return out.getvalue()
@pytest.mark.parametrize("format", ["JPEG", "PNG", "WEBP"])
@pytest.mark.parametrize("fit", ["contain", "cover", "smart"])
@pytest.mark.parametrize("profile", ["color", "grayscale"])
def test_conversion(format, fit, profile):
img = Image.open(BytesIO(convert(image_bytes(format), fit, profile)))
assert img.size == (1200, 1600)
assert img.format == "JPEG"
if profile == "grayscale":
assert len(set(img.getpixel((600, 800)))) == 1
def test_contain_preserves_shape():
img = Image.open(BytesIO(convert(image_bytes(), "contain")))
assert img.getpixel((600, 100)) == (255, 255, 255)
assert img.getpixel((600, 800))[0] > 240
def test_invalid_image():
with pytest.raises(FlowError):
convert(b"bad")
def test_transparency_white():
out = BytesIO()
Image.new("RGBA", (100, 100), (255, 0, 0, 0)).save(out, "PNG")
img = Image.open(BytesIO(convert(out.getvalue(), "cover")))
assert img.getpixel((600, 800)) == (255, 255, 255)
@pytest.mark.parametrize(
"template",
["daily-dashboard", "weather", "calendar", "quote", "photo-text", "system-alert"],
)
def test_templates(service, template):
result = service.renderer.render(template, {}, "color")
assert Image.open(BytesIO(result)).size == (1200, 1600)
def test_url_default_disabled(service):
with pytest.raises(FlowError):
service.assets.fetch_url("https://example.com/x.jpg")
@pytest.mark.parametrize(
"url",
[
"http://localhost/x",
"file:///etc/passwd",
"https://127.0.0.1/x",
"https://user:pass@example.com/x",
],
)
def test_url_ssrf(service, url):
with pytest.raises(FlowError):
service.assets.fetch_url(url)
+77
View File
@@ -0,0 +1,77 @@
from datetime import UTC, datetime, timedelta
import pytest
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import JobInput, PlaylistInput, Schedule
from pigway_frame_flow.scheduler.engine import next_run
@pytest.mark.parametrize("kind,day", [("daily", 4), ("weekdays", 4), ("weekends", 6)])
def test_schedules(kind, day):
start = datetime(2026, 10, 1, 0, 0, tzinfo=UTC)
result = datetime.fromisoformat(next_run(Schedule(type=kind, time="09:00"), start))
assert result.weekday() + 1 == day
def test_cron():
result = next_run(
Schedule(type="cron", cron="0 7 * * *"), datetime(2026, 10, 1, 0, 0, tzinfo=UTC)
)
assert result.startswith("2026-10-01T23:00")
@pytest.mark.parametrize("type", ["date", "temporary"])
def test_one_time(type):
current = datetime.now(UTC)
at = current + timedelta(hours=1)
assert next_run(Schedule(type=type, at=at), current) == at.isoformat()
assert next_run(Schedule(type=type, at=at), at) is None
def test_dedupe(app, service):
scheduler = app.state.scheduler
job = scheduler.save_job(
JobInput(
name="Morning",
schedule={"type": "daily", "time": "07:00"},
source={"type": "template", "template": "quote"},
)
)
due = (datetime.now(UTC) - timedelta(seconds=2)).isoformat()
service.db.execute("UPDATE jobs SET next_run=? WHERE id=?", (due, job["id"]))
scheduler.tick()
scheduler.tick()
assert len(service.db.all("SELECT * FROM operations")) == 1
assert len(service.db.all("SELECT * FROM job_runs")) == 1
def test_overdue_skipped(app, service):
scheduler = app.state.scheduler
job = scheduler.save_job(
JobInput(
name="Old",
schedule={"type": "daily", "time": "07:00"},
source={"type": "restore"},
)
)
service.db.execute(
"UPDATE jobs SET next_run=? WHERE id=?",
((datetime.now(UTC) - timedelta(hours=1)).isoformat(), job["id"]),
)
scheduler.tick()
assert scheduler.jobs()[0]["last_result"] == "SKIPPED_OVERDUE"
assert not service.db.all("SELECT * FROM operations")
def test_playlist(app, service):
scheduler = app.state.scheduler
scheduler.save_playlist(PlaylistInput(name="Art", image_ids=[1], enabled=True))
scheduler.tick()
scheduler.tick()
assert len(service.db.all("SELECT * FROM operations")) == 1
def test_invalid_cron():
with pytest.raises(FlowError):
next_run(Schedule(type="cron", cron="* * * * * *"))