- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table) - Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens - Active sessions management: list/revoke via /api/settings/sessions with device info - Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project) - Automatic daily backups: backup_db(), prune, scheduler + admin API - Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects() - GitHubAdapter implements ForgeAdapter contract, transport injection for mocking - Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs - Linting config: ruff (Python) + eslint (JS) - Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky) - Bumped version to 5.9.1 Co-authored-by: Bruno <[email protected]>
353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""FlowDeck — v5.1.0 automations: rules engine (trigger + condition + action).
|
|
|
|
Covers migration v5, the automations CRUD API, the if-this-then-that engine
|
|
(condition matching + action execution on collection-page events), manual/button
|
|
runs, and the cron due logic.
|
|
"""
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
db_path = db_file.name
|
|
db_file.close()
|
|
|
|
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
|
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
|
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
|
from app.config import settings
|
|
settings.database_url = f"sqlite:///{db_path}"
|
|
|
|
from app.db import get_conn, init_db
|
|
from app.main import app
|
|
init_db()
|
|
with get_conn() as conn:
|
|
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (1, 'tester', 'Tester', 1)")
|
|
conn.commit()
|
|
|
|
tc = TestClient(app)
|
|
yield tc
|
|
|
|
os.unlink(db_path)
|
|
|
|
|
|
def _make_collection(client, name="Autos DB"):
|
|
r = client.post("/db/api", json={"name": name, "schema": [
|
|
{"id": 1, "name": "Status", "prop_type": "select"},
|
|
{"id": 2, "name": "Priority", "prop_type": "text"},
|
|
]})
|
|
return r.json()["id"]
|
|
|
|
|
|
def _make_page(client, coll_id, title="Page", props=None):
|
|
r = client.post(f"/db/{coll_id}/pages/api", json={"title": title, "properties": props or {}})
|
|
return r.json()["id"]
|
|
|
|
|
|
# ── Migration v5 ──
|
|
|
|
def test_automations_tables_created_by_migration(client):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
tables = {r["name"] for r in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
).fetchall()}
|
|
assert "automations" in tables
|
|
assert "automation_runs" in tables
|
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
|
|
assert {"trigger_type", "event", "condition_json", "actions_json", "enabled"} <= cols
|
|
runs = {r[1] for r in conn.execute("PRAGMA table_info(automation_runs)").fetchall()}
|
|
assert {"automation_id", "status", "detail", "trigger_source"} <= runs
|
|
|
|
|
|
# ── Condition matching (service) ──
|
|
|
|
def test_condition_matching_service():
|
|
from app.services.automations import evaluate_conditions
|
|
|
|
assert evaluate_conditions("[]", {"Status": "Done"}) is True
|
|
assert evaluate_conditions('[{"property":"Status","op":"eq","value":"Done"}]', {"Status": "Done"}) is True
|
|
assert evaluate_conditions('[{"property":"Status","op":"eq","value":"Done"}]', {"Status": "Todo"}) is False
|
|
assert evaluate_conditions('[{"property":"Priority","op":"is_empty"}]', {"Status": "Done"}) is True
|
|
assert evaluate_conditions('[{"property":"Priority","op":"is_not_empty"}]', {"Priority": "High"}) is True
|
|
assert evaluate_conditions('[{"property":"Title","op":"contains","value":"urgent"}]', {"Title": "very urgent"}) is True
|
|
# AND semantics
|
|
assert evaluate_conditions(
|
|
'[{"property":"Status","op":"eq","value":"Done"},{"property":"Priority","op":"neq","value":"Low"}]',
|
|
{"Status": "Done", "Priority": "High"},
|
|
) is True
|
|
assert evaluate_conditions(
|
|
'[{"property":"Status","op":"eq","value":"Done"},{"property":"Priority","op":"neq","value":"Low"}]',
|
|
{"Status": "Done", "Priority": "Low"},
|
|
) is False
|
|
# changed only valid with before_props
|
|
assert evaluate_conditions('[{"property":"Status","op":"changed"}]', {"Status": "Done"},
|
|
{"Status": "Todo"}) is True
|
|
assert evaluate_conditions('[{"property":"Status","op":"changed"}]', {"Status": "Done"},
|
|
{"Status": "Done"}) is False
|
|
|
|
|
|
# ── CRUD API ──
|
|
|
|
def test_automation_crud(client):
|
|
# create
|
|
r = client.post("/workspace/automations", json={
|
|
"name": "Notify when done",
|
|
"trigger_type": "event",
|
|
"event": "page.updated",
|
|
"collection_id": None,
|
|
"condition_json": '[{"property":"Status","op":"eq","value":"Done"}]',
|
|
"actions_json": '[{"type":"notify","message":"Reacted to Done"}]',
|
|
})
|
|
assert r.status_code == 200, r.text
|
|
aid = r.json()["id"]
|
|
|
|
# list
|
|
d = client.get("/workspace/automations").json()
|
|
assert len(d["automations"]) == 1
|
|
assert d["automations"][0]["name"] == "Notify when done"
|
|
|
|
# get
|
|
assert client.get(f"/workspace/automations/{aid}").json()["id"] == aid
|
|
|
|
# update
|
|
r = client.put(f"/workspace/automations/{aid}", json={
|
|
"name": "Renamed",
|
|
"trigger_type": "event",
|
|
"event": "page.created",
|
|
"collection_id": None,
|
|
"condition_json": "[]",
|
|
"actions_json": "[]",
|
|
})
|
|
assert r.status_code == 200
|
|
assert client.get(f"/workspace/automations/{aid}").json()["name"] == "Renamed"
|
|
|
|
# validation: name required
|
|
r = client.post("/workspace/automations", json={"name": " "})
|
|
assert r.status_code == 400
|
|
# validation: bad actions JSON
|
|
r = client.post("/workspace/automations", json={
|
|
"name": "Bad", "trigger_type": "event", "event": "page.created",
|
|
"actions_json": "not json",
|
|
})
|
|
assert r.status_code == 400
|
|
|
|
# delete
|
|
assert client.delete(f"/workspace/automations/{aid}").status_code == 200
|
|
# After delete: the app's HTML 404 handler redirects non-API GETs to /workspaces.
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id FROM automations WHERE id=?", (aid,)).fetchone()
|
|
assert row is None
|
|
|
|
|
|
def test_automation_scope_collection_filter(client):
|
|
c1 = _make_collection(client, "C1")
|
|
c2 = _make_collection(client, "C2")
|
|
only_c1 = client.post("/workspace/automations", json={
|
|
"name": "Only C1",
|
|
"trigger_type": "event",
|
|
"event": "page.created",
|
|
"collection_id": c1,
|
|
"condition_json": "[]",
|
|
"actions_json": '[{"type":"notify","message":"page in C1"}]',
|
|
}).json()["id"]
|
|
|
|
p_c1 = _make_page(client, c1, props={"Status": "Todo"})
|
|
p_c2 = _make_page(client, c2, props={"Status": "Todo"})
|
|
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
runs_c1 = conn.execute(
|
|
"SELECT * FROM automation_runs WHERE automation_id=? AND status='fired'",
|
|
(only_c1,),
|
|
).fetchall()
|
|
# Only page in C1 should have triggered the automation.
|
|
assert len(runs_c1) == 1
|
|
assert runs_c1[0]["page_id"] == p_c1
|
|
|
|
|
|
# ── Event engine: fire on collection-page events ──
|
|
|
|
def test_event_trigger_set_property_action(client):
|
|
coll = _make_collection(client)
|
|
pid = _make_page(client, coll, props={"Status": "Todo"})
|
|
|
|
client.post("/workspace/automations", json={
|
|
"name": "Mark archived",
|
|
"trigger_type": "event",
|
|
"event": "page.updated",
|
|
"collection_id": coll,
|
|
"condition_json": '[{"property":"Status","op":"eq","value":"Archived"}]',
|
|
"actions_json": '[{"type":"set_property","property":"Priority","value":"Low"}]',
|
|
})
|
|
|
|
assert client.put(f"/db/pages/{pid}/api", json={
|
|
"properties": {"Status": "Archived", "Priority": "High"},
|
|
}).status_code == 200
|
|
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT property_values_json FROM collection_pages WHERE id=?", (pid,)
|
|
).fetchone()
|
|
props = __import__("json").loads(row["property_values_json"])
|
|
assert props["Priority"] == "Low" # action overrode the value
|
|
assert props["Status"] == "Archived" # original value preserved
|
|
|
|
# The action should not re-fire (DB-level write, not via API)
|
|
with get_conn() as conn:
|
|
runs = conn.execute(
|
|
"SELECT COUNT(*) AS c FROM automation_runs WHERE status='fired'"
|
|
).fetchone()["c"]
|
|
assert runs == 1
|
|
|
|
|
|
def test_event_trigger_skipped_when_condition_fails(client):
|
|
coll = _make_collection(client)
|
|
pid = _make_page(client, coll, props={"Status": "Todo"})
|
|
client.post("/workspace/automations", json={
|
|
"name": "Skip unless Done",
|
|
"trigger_type": "event",
|
|
"event": "page.updated",
|
|
"collection_id": coll,
|
|
"condition_json": '[{"property":"Status","op":"eq","value":"Done"}]',
|
|
"actions_json": '[{"type":"notify","message":"done!"}]',
|
|
})
|
|
client.put(f"/db/pages/{pid}/api", json={"properties": {"Status": "Todo"}})
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT status FROM automation_runs WHERE automation_id=(SELECT id FROM automations LIMIT 1)"
|
|
).fetchone()
|
|
assert row["status"] == "skipped"
|
|
|
|
|
|
def test_page_deleted_fires_event(client):
|
|
coll = _make_collection(client)
|
|
pid = _make_page(client, coll)
|
|
client.post("/workspace/automations", json={
|
|
"name": "On delete",
|
|
"trigger_type": "event",
|
|
"event": "page.deleted",
|
|
"collection_id": None,
|
|
"condition_json": "[]",
|
|
"actions_json": '[{"type":"notify","message":"page deleted"}]',
|
|
})
|
|
assert client.delete(f"/db/pages/{pid}/api").status_code == 200
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
runs = conn.execute("SELECT * FROM automation_runs WHERE status='fired'").fetchall()
|
|
assert len(runs) == 1
|
|
assert runs[0]["trigger_source"] == "event"
|
|
|
|
|
|
def test_create_page_action(client):
|
|
coll = _make_collection(client)
|
|
target = _make_collection(client, "Target")
|
|
_make_page(client, coll, props={"Status": "Go"})
|
|
client.post("/workspace/automations", json={
|
|
"name": "Spawn a page",
|
|
"trigger_type": "event",
|
|
"event": "page.updated",
|
|
"collection_id": coll,
|
|
"condition_json": '[{"property":"Status","op":"contains","value":"Go"}]',
|
|
"actions_json": json_str([{"type": "create_page", "collection_id": target,
|
|
"title": "Generated from {{title}}",
|
|
"properties": {"Status": "Todo"}}]),
|
|
})
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
src = conn.execute(
|
|
"SELECT id FROM collection_pages WHERE collection_id=? ORDER BY id DESC LIMIT 1",
|
|
(coll,),
|
|
).fetchone()["id"]
|
|
client.put(f"/db/pages/{src}/api", json={"properties": {"Status": "Go"}})
|
|
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT title FROM collection_pages WHERE collection_id=? ORDER BY id DESC",
|
|
(target,),
|
|
).fetchall()
|
|
assert any("Generated from" in (r["title"] or "") for r in rows)
|
|
|
|
|
|
# ── Manual / button run ──
|
|
|
|
def test_button_run_endpoint(client):
|
|
coll = _make_collection(client)
|
|
pid = _make_page(client, coll, props={"Status": "Todo"})
|
|
auto_id = client.post("/workspace/automations", json={
|
|
"name": "Button action",
|
|
"trigger_type": "event",
|
|
"event": "page.updated",
|
|
"collection_id": None,
|
|
"condition_json": "[]",
|
|
"actions_json": '[{"type":"set_property","property":"Status","value":"Clicked"}]',
|
|
}).json()["id"]
|
|
|
|
r = client.post(f"/api/automations/{auto_id}/run", json={"page_id": pid})
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "fired"
|
|
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
props = __import__("json").loads(conn.execute(
|
|
"SELECT property_values_json FROM collection_pages WHERE id=?", (pid,)
|
|
).fetchone()["property_values_json"])
|
|
assert props["Status"] == "Clicked"
|
|
|
|
# history recorded
|
|
d = client.get(f"/workspace/automations/{auto_id}/runs").json()
|
|
assert len(d["runs"]) == 1
|
|
assert d["runs"][0]["trigger_source"] == "button"
|
|
|
|
|
|
def test_manual_run_skipped_for_disabled(client):
|
|
auto_id = client.post("/workspace/automations", json={
|
|
"name": "Disabled",
|
|
"trigger_type": "event",
|
|
"event": "page.created",
|
|
"collection_id": None,
|
|
"condition_json": "[]",
|
|
"actions_json": "[]",
|
|
"enabled": False,
|
|
}).json()["id"]
|
|
r = client.post(f"/workspace/automations/{auto_id}/run", json={})
|
|
assert r.json()["status"] == "skipped"
|
|
|
|
|
|
# ── Cron due logic ──
|
|
|
|
def test_cron_due():
|
|
from datetime import datetime, timedelta
|
|
|
|
from app.services.automations import cron_due
|
|
|
|
now = datetime(2026, 9, 7, 10, 15, 0)
|
|
# every 15 minutes: due when >= 15 minutes elapsed, otherwise not
|
|
last = (now - timedelta(minutes=15)).isoformat()
|
|
assert cron_due("*/15 * * * *", last, now) is True
|
|
last = (now - timedelta(minutes=10)).isoformat()
|
|
assert cron_due("*/15 * * * *", last, now) is False
|
|
# no last run → always due
|
|
assert cron_due("*/30 * * * *", None, now) is True
|
|
# fixed minute
|
|
assert cron_due("15 * * * *", None, now) is True
|
|
assert cron_due("45 * * * *", None, now) is False
|
|
# @hourly / @daily
|
|
assert cron_due("@hourly", None, now) is True
|
|
assert cron_due("@daily", (now - timedelta(hours=25)).isoformat(), now) is True
|
|
assert cron_due("@daily", (now - timedelta(hours=2)).isoformat(), now) is False
|
|
assert cron_due(" ", None, now) is False
|
|
|
|
|
|
def json_str(obj):
|
|
import json
|
|
return json.dumps(obj)
|