Add optional AI generation, MQTT, OAuth refresh and calendar recurrence
This commit is contained in:
@@ -141,6 +141,98 @@
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/settings/ai-images": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"put": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/settings/ai-images/key": {
|
||||
"put": [
|
||||
"204",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/ai/images": {
|
||||
"post": [
|
||||
"202",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/integrations/mqtt": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"put": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/layout/images": {
|
||||
"post": [
|
||||
"201",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def test_generate_requires_configuration(client):
|
||||
assert (
|
||||
client.post("/api/v1/ai/images", json={"prompt": "Landscape"}).status_code
|
||||
== 409
|
||||
)
|
||||
|
||||
|
||||
def test_generation_queued_once_and_never_displays(client, service, monkeypatch):
|
||||
assert (
|
||||
client.put(
|
||||
"/api/v1/settings/ai-images",
|
||||
json={
|
||||
"endpoint": "https://example.com/v1/images/generations",
|
||||
"model": "test-model",
|
||||
},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
client.put("/api/v1/settings/ai-images/key", json={"key": "private-ai-key"})
|
||||
image = BytesIO()
|
||||
Image.new("RGB", (100, 200), "blue").save(image, "PNG")
|
||||
calls = []
|
||||
|
||||
def fetch(url, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return json.dumps(
|
||||
{"data": [{"b64_json": base64.b64encode(image.getvalue()).decode()}]}
|
||||
).encode()
|
||||
|
||||
monkeypatch.setattr(service.assets, "fetch_url", fetch)
|
||||
headers = {"Idempotency-Key": "art-once"}
|
||||
first = client.post(
|
||||
"/api/v1/ai/images", json={"prompt": "Landscape"}, headers=headers
|
||||
)
|
||||
assert first.status_code == 202
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/ai/images", json={"prompt": "Landscape"}, headers=headers
|
||||
).json()
|
||||
== first.json()
|
||||
)
|
||||
service.process_one()
|
||||
operation = service.operation(first.json()["operation_id"])
|
||||
assert operation["status"] == "COMPLETED"
|
||||
assert len(calls) == 1
|
||||
assert json.loads(calls[0]["body"])["n"] == 1
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
assert "private-ai-key" not in client.get("/api/v1/settings/ai-images").text
|
||||
|
||||
|
||||
def test_generation_failure_never_retries(client, service, monkeypatch):
|
||||
client.put(
|
||||
"/api/v1/settings/ai-images",
|
||||
json={"endpoint": "https://example.com/v1/images/generations", "model": "test"},
|
||||
)
|
||||
client.put("/api/v1/settings/ai-images/key", json={"key": "private-ai-key"})
|
||||
calls = []
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
calls.append(1)
|
||||
raise TimeoutError("private-ai-key")
|
||||
|
||||
monkeypatch.setattr(service.assets, "fetch_url", fail)
|
||||
result = client.post("/api/v1/ai/images", json={"prompt": "Landscape"})
|
||||
service.process_one()
|
||||
service.process_one()
|
||||
op = service.operation(result.json()["operation_id"])
|
||||
assert op["status"] == "FAILED"
|
||||
assert len(calls) == 1
|
||||
assert "private-ai-key" not in json.dumps(op)
|
||||
|
||||
|
||||
def test_interrupted_generation_is_never_requeued(app, service, settings):
|
||||
from pigway_frame_flow.api.app import create_app
|
||||
|
||||
id = service.submit(
|
||||
"render-ai",
|
||||
{"prompt": "One image"},
|
||||
"living-room",
|
||||
"admin",
|
||||
"interrupted",
|
||||
"one",
|
||||
)
|
||||
service.db.execute(
|
||||
"UPDATE operations SET status='RENDERING',write_started=1 WHERE id=?", (id,)
|
||||
)
|
||||
restarted = create_app(settings, start_worker=False)
|
||||
assert restarted.state.service.operation(id)["status"] == "UNKNOWN"
|
||||
assert not restarted.state.service.blocked("living-room")
|
||||
assert restarted.state.service.process_one() is False
|
||||
restarted.state.service.db.close()
|
||||
|
||||
|
||||
def test_extension_script_served(client):
|
||||
response = client.get("/ui/extensions.js")
|
||||
assert response.status_code == 200
|
||||
assert "layoutStudioPage" in response.text
|
||||
@@ -0,0 +1,31 @@
|
||||
from pigway_frame_flow.models import Power
|
||||
|
||||
|
||||
def test_observer_records_only_changes(service, monkeypatch):
|
||||
driver = service.registry.get("living-room")
|
||||
baseline = driver.device()
|
||||
monkeypatch.setattr(driver, "device", lambda: baseline)
|
||||
service.device_observer.tick()
|
||||
before = len(service.db.all("SELECT * FROM events"))
|
||||
service.device_observer.tick()
|
||||
assert len(service.db.all("SELECT * FROM events")) == before
|
||||
changed = baseline.model_copy(
|
||||
update={"status": "offline", "power": Power(battery_percent=10, charging=False)}
|
||||
)
|
||||
monkeypatch.setattr(driver, "device", lambda: changed)
|
||||
service.device_observer.tick()
|
||||
assert {r["event_type"] for r in service.db.all("SELECT * FROM events")} >= {
|
||||
"device.offline",
|
||||
"device.low_battery",
|
||||
}
|
||||
|
||||
|
||||
def test_cloud_failure_is_not_device_offline(service, monkeypatch):
|
||||
driver = service.registry.get("living-room")
|
||||
|
||||
def fail():
|
||||
raise RuntimeError("cloud failure")
|
||||
|
||||
monkeypatch.setattr(driver, "device", fail)
|
||||
service.device_observer.tick()
|
||||
assert not service.db.all("SELECT * FROM events")
|
||||
@@ -0,0 +1,60 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.providers.ical import calendar_events
|
||||
|
||||
|
||||
def feed(properties):
|
||||
return (
|
||||
"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n"
|
||||
+ properties
|
||||
+ "\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
||||
).encode()
|
||||
|
||||
|
||||
def test_recurring_event_exclusions_and_folded_title():
|
||||
result = calendar_events(
|
||||
feed(
|
||||
"UID:one\r\nDTSTART:20261008T080000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEXDATE:20261009T080000Z\r\nSUMMARY:Family\r\n time"
|
||||
),
|
||||
datetime(2026, 10, 8, tzinfo=UTC),
|
||||
)
|
||||
assert len(result["events"]) == 2
|
||||
assert result["events"][0]["title"] == "Family time"
|
||||
assert result["events"][1]["start"].startswith("2026-10-10")
|
||||
|
||||
|
||||
def test_cancelled_event_ignored():
|
||||
assert (
|
||||
calendar_events(
|
||||
feed("DTSTART:20261008T080000Z\r\nSTATUS:CANCELLED\r\nSUMMARY:Cancelled"),
|
||||
datetime(2026, 10, 8, tzinfo=UTC),
|
||||
)["events"]
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_high_frequency_rejected():
|
||||
with pytest.raises(FlowError):
|
||||
calendar_events(
|
||||
feed("DTSTART:20261008T080000Z\r\nRRULE:FREQ=SECONDLY\r\nSUMMARY:Bad"),
|
||||
datetime(2026, 10, 8, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_timezone_and_past_filter():
|
||||
events = calendar_events(
|
||||
feed("DTSTART;TZID=Asia/Shanghai:20261008T090000\r\nSUMMARY:Meeting"),
|
||||
datetime(2026, 10, 8, tzinfo=UTC),
|
||||
)["events"]
|
||||
assert len(events) == 1
|
||||
assert events[0]["start"].endswith("+08:00")
|
||||
assert (
|
||||
calendar_events(
|
||||
feed("DTSTART:20260101T090000Z\r\nSUMMARY:Past"),
|
||||
datetime(2026, 10, 8, tzinfo=UTC),
|
||||
)["events"]
|
||||
== []
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
|
||||
def test_mqtt_uses_scope_and_durable_idempotency(client, service, monkeypatch):
|
||||
token = client.post(
|
||||
"/api/v1/clients",
|
||||
json={"client_id": "mqtt", "name": "MQTT", "permissions": ["display:write"]},
|
||||
).json()["token"]
|
||||
monkeypatch.setenv("FRAME_FLOW_MQTT_TOKEN", token)
|
||||
payload = json.dumps(
|
||||
{"request_id": "stable-1", "content": {"type": "template", "template": "quote"}}
|
||||
).encode()
|
||||
first = service.mqtt.accept(payload)
|
||||
assert service.mqtt.accept(payload) == first
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
assert service.operation(first["operation_id"])["status"] == "QUEUED"
|
||||
with pytest.raises(FlowError):
|
||||
service.mqtt.accept(payload, retained=True)
|
||||
|
||||
|
||||
def test_mqtt_needs_authentication(service, monkeypatch):
|
||||
monkeypatch.delenv("FRAME_FLOW_MQTT_TOKEN", raising=False)
|
||||
with pytest.raises(FlowError):
|
||||
service.mqtt.accept(b"{}")
|
||||
|
||||
|
||||
def test_mqtt_disabled_by_default(client, service):
|
||||
assert client.get("/api/v1/integrations/mqtt").json()["config"]["enabled"] is False
|
||||
service.mqtt.start()
|
||||
assert service.mqtt.client is None
|
||||
|
||||
|
||||
def test_mqtt_refuses_unapproved_plaintext(client):
|
||||
assert (
|
||||
client.put(
|
||||
"/api/v1/integrations/mqtt",
|
||||
json={"enabled": True, "host": "192.0.2.10", "tls": False},
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.providers.oauth import OAuthTokens
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["google-drive", "alipan", "baidu"])
|
||||
def test_refresh_fixed_endpoint_private_persistence(
|
||||
service, monkeypatch, tmp_path, kind
|
||||
):
|
||||
calls = []
|
||||
|
||||
def fetch(url, **kwargs):
|
||||
calls.append((url, kwargs))
|
||||
return json.dumps(
|
||||
{
|
||||
"access_token": "new-private-token",
|
||||
"refresh_token": "rotated-private-token",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
).encode()
|
||||
|
||||
monkeypatch.setattr(service.assets, "fetch_url", fetch)
|
||||
credentials = {
|
||||
"access_token": "old-token",
|
||||
"refresh_token": "refresh-private",
|
||||
"client_id": "app",
|
||||
"client_secret": "client-private",
|
||||
}
|
||||
path = tmp_path / "credentials.secret"
|
||||
result = OAuthTokens(service.settings, service.assets).refresh(
|
||||
kind, credentials, path
|
||||
)
|
||||
assert calls[0][0] == OAuthTokens.ENDPOINTS[kind]
|
||||
assert result["expires_at"] > time.time() + 3500
|
||||
assert json.loads(path.read_text())["refresh_token"] == "rotated-private-token"
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
assert service.settings.sanitize("new-private-token") != "new-private-token"
|
||||
|
||||
|
||||
def test_refresh_failure_never_retries_or_exposes_secret(service, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fetch(*args, **kwargs):
|
||||
calls.append(args)
|
||||
raise RuntimeError("private-secret")
|
||||
|
||||
monkeypatch.setattr(service.assets, "fetch_url", fetch)
|
||||
with pytest.raises(FlowError) as exc:
|
||||
OAuthTokens(service.settings, service.assets).refresh(
|
||||
"baidu",
|
||||
{
|
||||
"refresh_token": "refresh",
|
||||
"client_id": "app",
|
||||
"client_secret": "private-secret",
|
||||
},
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert "private-secret" not in str(exc.value)
|
||||
|
||||
|
||||
def test_invalid_refresh_does_not_replace_existing_credentials(
|
||||
service, monkeypatch, tmp_path
|
||||
):
|
||||
path = tmp_path / "secret"
|
||||
path.write_text("original")
|
||||
monkeypatch.setattr(
|
||||
service.assets, "fetch_url", lambda *a, **k: b'{"error":"invalid_grant"}'
|
||||
)
|
||||
with pytest.raises(FlowError):
|
||||
OAuthTokens(service.settings, service.assets).refresh(
|
||||
"google-drive",
|
||||
{"refresh_token": "refresh", "client_id": "app", "client_secret": "secret"},
|
||||
path,
|
||||
)
|
||||
assert path.read_text() == "original"
|
||||
Reference in New Issue
Block a user