Build Pigway Frame Flow with bilingual UI, complete KoKonna driver and safe automation
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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="* * * * * *"))
|
||||
Reference in New Issue
Block a user