Files
flowdeck/tests/test_app.py
T
bruno eb417d4ed5
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
fix: dashboard tests — accept redirect when no Gitea token in test DB
2026-07-10 16:32:33 -04:00

1007 lines
32 KiB
Python

"""FlowDeck — Comprehensive tests v1.3.0."""
import json
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["GITEA_URL"] = "https://git.dracodev.net"
os.environ["GITEA_TOKEN"] = "test"
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
from app.main import app
from app.db import init_db
init_db()
yield TestClient(app)
os.unlink(db_path)
# ── Core ──
def test_health(client):
resp = client.get("/api/health")
assert resp.status_code == 200
data = resp.json()
assert "status" in data
assert data["db"] is True
assert data["version"] == "2.1.0"
def test_dashboard(client):
resp = client.get("/")
# DB empty → admin user → no Gitea token → redirect to local-workspace
# which requires auth → redirect to login page
assert resp.status_code in (200, 302)
def test_stats(client):
resp = client.get("/api/stats")
assert resp.status_code == 200
data = resp.json()
for key in ("boards", "cards", "notes", "users"):
assert key in data
def test_projects(client):
resp = client.get("/api/projects")
assert resp.status_code == 200
data = resp.json()
assert "projects" in data
def test_board_404(client):
resp = client.get("/board/test/test")
assert resp.status_code in (200, 500)
def test_csrf_rejected(client):
resp = client.post("/api/move?owner=x&repo=y&issue_id=1&column=Test")
assert resp.status_code == 403
def test_auth_user(client):
resp = client.get("/auth/user")
assert resp.status_code == 200
assert "authenticated" in resp.json()
# ── v0.4.0: UI Notion ──
def test_board_page_renders(client):
resp = client.get("/board/test/test")
assert resp.status_code in (200, 500)
if resp.status_code == 200:
assert "kanban" in resp.text.lower() or "board" in resp.text.lower()
def test_dashboard_notion_ui(client):
resp = client.get("/")
assert resp.status_code in (200, 302)
if resp.status_code == 302:
assert "local-workspace" in resp.headers.get("location", "")
# ── v0.5.0: Kanban ──
def test_kanban_view(client):
resp = client.get("/board/test/test/view/kanban")
# May 500 if Gitea is unreachable, but shouldn't crash
assert resp.status_code in (200, 500)
def test_detailed_view(client):
resp = client.get("/board/test/test/view/detailed")
assert resp.status_code in (200, 500)
def test_card_detail_html(client):
resp = client.get("/api/issues/test/test/1?format=html")
# 404 expected if issue doesn't exist
assert resp.status_code in (200, 404, 500)
def test_create_issue_api(client):
resp = client.post("/api/issues/test/test?title=Test%20Issue&body=Test%20body")
# 403 CSRF or 500 if Gitea down
assert resp.status_code in (403, 500)
# ── v0.6.0: Table View ──
def test_table_view(client):
resp = client.get("/board/test/test/view/table")
assert resp.status_code in (200, 500)
if resp.status_code == 200:
assert "table" in resp.text.lower() or "data-table" in resp.text
def test_table_view_with_sort(client):
resp = client.get("/board/test/test/view/table?sort=name:asc")
assert resp.status_code in (200, 500)
# ── v0.7.0: Filtres & Tri ──
def test_kanban_with_status_filter(client):
resp = client.get("/board/test/test/view/kanban?status=todo,progress")
assert resp.status_code in (200, 500)
def test_kanban_with_assignee_filter(client):
resp = client.get("/board/test/test/view/kanban?filter=assignee:testuser")
assert resp.status_code in (200, 500)
def test_kanban_with_multiple_sorts(client):
resp = client.get("/board/test/test/view/kanban?sort=status:asc,name:desc")
assert resp.status_code in (200, 500)
# ── v0.8.0: Vues Spéciales ──
def test_status_overview(client):
resp = client.get("/board/test/test/view/status")
assert resp.status_code in (200, 500)
if resp.status_code == 200:
assert "svg" in resp.text.lower() or "donut" in resp.text.lower() or "status_data" in resp.text or "Total" in resp.text
def test_team_load(client):
resp = client.get("/board/test/test/view/teamload")
assert resp.status_code in (200, 500)
# ── v0.9.0: Backend ──
def test_get_properties(client):
resp = client.get("/board/api/properties/test/test")
assert resp.status_code == 200
data = resp.json()
assert "properties" in data
def test_get_ai_keywords(client):
resp = client.get("/board/api/ai-keywords/test/test")
assert resp.status_code == 200
data = resp.json()
assert "keywords" in data
def test_csrf_protects_properties_post(client):
resp = client.post("/board/api/properties/test/test?name=Priority&prop_type=select")
assert resp.status_code == 403 # CSRF
def test_csrf_protects_sync(client):
resp = client.post("/board/api/sync/test/test")
assert resp.status_code == 403 # CSRF
# ── v1.0.0: Production ──
def test_version_in_health(client):
resp = client.get("/api/health")
assert resp.json()["version"] == "2.1.0"
def test_db_tables_exist(client):
from app.db import get_conn
with get_conn() as conn:
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
names = {t["name"] for t in tables}
required = {"boards", "cards", "notes", "checklists", "checklist_items",
"col_mapping", "users", "user_tokens",
"project_properties", "property_values", "ai_keywords",
"collections", "collection_pages", "collection_views",
"collection_properties", "workspaces", "workspace_members",
"comments", "page_history", "favorites",
"database_templates", "page_templates"}
assert required <= names
def test_cors_headers(client):
resp = client.options("/api/health", headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "GET",
})
assert resp.status_code in (200, 405)
def test_all_view_endpoints_respond(client):
views = ["kanban", "detailed", "table", "status", "teamload"]
for view in views:
resp = client.get(f"/board/test/test/view/{view}")
assert resp.status_code in (200, 500), f"View {view} failed with {resp.status_code}"
# ── v1.3.0: Database Concept ──
def test_collections_api_list_empty(client):
"""List collections API — should return empty when no collections exist."""
resp = client.get("/db/api")
assert resp.status_code == 200
data = resp.json()
assert "collections" in data
assert data["collections"] == []
def test_collections_api_crud(client):
"""Full CRUD lifecycle: create → read → update → delete."""
# Create
resp = client.post("/db/api", json={
"name": "Test Database",
"description": "A test collection",
"icon": "🗂️",
"schema": [
{"name": "Status", "type": "select", "options": ["Todo", "Done"]},
],
})
assert resp.status_code == 200
created = resp.json()
assert created["status"] == "created"
assert "id" in created
coll_id = created["id"]
# List — should now have 1 collection
resp = client.get("/db/api")
assert resp.status_code == 200
assert len(resp.json()["collections"]) == 1
# Get single
resp = client.get(f"/db/{coll_id}/api")
assert resp.status_code == 200
data = resp.json()
assert data["collection"]["name"] == "Test Database"
assert data["collection"]["icon"] == "🗂️"
# Update
resp = client.put(f"/db/api/{coll_id}", json={
"name": "Updated DB",
})
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
# Verify update
resp = client.get(f"/db/{coll_id}/api")
assert resp.json()["collection"]["name"] == "Updated DB"
# Delete
resp = client.delete(f"/db/api/{coll_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
# Verify deletion
resp = client.get(f"/db/{coll_id}/api")
assert resp.status_code == 404
def test_collections_pages_crud(client):
"""CRUD for pages inside a collection."""
# Create collection first
resp = client.post("/db/api", json={"name": "Page Test DB"})
coll_id = resp.json()["id"]
# Create page
resp = client.post(f"/db/{coll_id}/pages/api", json={
"title": "My First Page",
"properties": {"Status": "Todo", "Priority": "P1"},
})
assert resp.status_code == 200
page_data = resp.json()
assert page_data["status"] == "created"
page_id = page_data["id"]
# Get page
resp = client.get(f"/db/pages/{page_id}/api")
assert resp.status_code == 200
assert resp.json()["title"] == "My First Page"
# Update page
resp = client.put(f"/db/pages/{page_id}/api", json={
"title": "Updated Page",
"properties": {"Status": "Done"},
})
assert resp.status_code == 200
# Verify update
resp = client.get(f"/db/pages/{page_id}/api")
data = resp.json()
assert data["title"] == "Updated Page"
props = json.loads(data["property_values_json"])
assert props["Status"] == "Done"
# Delete page
resp = client.delete(f"/db/pages/{page_id}/api")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
# Cleanup: delete collection
client.delete(f"/db/api/{coll_id}")
def test_collections_api_validation(client):
"""Validation: missing name should return 400."""
resp = client.post("/db/api", json={})
assert resp.status_code == 400
assert "name" in resp.json()["detail"].lower()
def test_collections_db_page_renders(client):
"""GET /db/{id} should render an HTML page."""
# Create collection first
resp = client.post("/db/api", json={"name": "Render Test"})
coll_id = resp.json()["id"]
resp = client.get(f"/db/{coll_id}")
assert resp.status_code == 200
assert "Render Test" in resp.text
# Cleanup
client.delete(f"/db/api/{coll_id}")
def test_boards_as_collections(client):
"""GET /db/boards/api — should list boards as pseudo-collections."""
resp = client.get("/db/boards/api")
assert resp.status_code == 200
data = resp.json()
assert "boards" in data
assert isinstance(data["boards"], list)
# ── v2.1.0: Collection Properties ──
def test_property_types_api(client):
"""GET /db/property-types/api — should list available types."""
resp = client.get("/db/property-types/api")
assert resp.status_code == 200
data = resp.json()
assert "types" in data
assert "text" in data["types"]
assert "number" in data["types"]
assert "checkbox" in data["types"]
assert "status" in data["types"]
def test_collection_properties_crud(client):
"""Full CRUD on collection properties."""
# Create collection
resp = client.post("/db/api", json={"name": "Props Test DB"})
coll_id = resp.json()["id"]
# List properties (empty)
resp = client.get(f"/db/{coll_id}/properties/api")
assert resp.status_code == 200
assert resp.json()["properties"] == []
# Create a text property
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "Description",
"prop_type": "text",
})
assert resp.status_code == 200
assert resp.json()["status"] == "created"
prop_id = resp.json()["id"]
# Create a number property
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "Estimation",
"prop_type": "number",
"number_format": "number",
})
assert resp.status_code == 200
# Create a status property with options
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "State",
"prop_type": "status",
"options": [
{"name": "Todo", "color": "gray"},
{"name": "Done", "color": "green"},
],
})
assert resp.status_code == 200
# Create a checkbox property
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "Verified",
"prop_type": "checkbox",
})
assert resp.status_code == 200
# List — should have 4
resp = client.get(f"/db/{coll_id}/properties/api")
assert len(resp.json()["properties"]) == 4
# Update a property
resp = client.put(f"/db/properties/{prop_id}/api", json={
"name": "Description Longue",
})
assert resp.status_code == 200
# Verify update
resp = client.get(f"/db/{coll_id}/properties/api")
names = [p["name"] for p in resp.json()["properties"]]
assert "Description Longue" in names
# Delete a property
resp = client.delete(f"/db/properties/{prop_id}/api")
assert resp.status_code == 200
# Verify deletion
resp = client.get(f"/db/{coll_id}/properties/api")
assert len(resp.json()["properties"]) == 3
# Cleanup
client.delete(f"/db/api/{coll_id}")
def test_collection_properties_duplicate(client):
"""Creating duplicate property name should return 409."""
resp = client.post("/db/api", json={"name": "Dup Test"})
coll_id = resp.json()["id"]
client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
resp = client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
assert resp.status_code == 409
client.delete(f"/db/api/{coll_id}")
def test_collection_properties_validation(client):
"""Validation: missing name should return 400."""
resp = client.post("/db/api", json={"name": "Val Test"})
coll_id = resp.json()["id"]
resp = client.post(f"/db/{coll_id}/properties/api", json={})
assert resp.status_code == 400
client.delete(f"/db/api/{coll_id}")
# ── v2.1.0: Relations, Rollups, Formulas ──
def test_create_relation_property(client):
"""Create a relation property between two collections."""
r1 = client.post("/db/api", json={"name": "Projects"})
r2 = client.post("/db/api", json={"name": "Tasks"})
c1, c2 = r1.json()["id"], r2.json()["id"]
resp = client.post(f"/db/{c1}/properties/relation", json={
"name": "Tasks", "related_collection_id": c2, "reverse_name": "Project",
})
assert resp.status_code == 200
assert resp.json()["prop_type"] == "relation"
resp2 = client.get(f"/db/{c2}/properties/api")
names = [p["name"] for p in resp2.json()["properties"]]
assert "Project" in names
client.delete(f"/db/api/{c2}") # c2 first (has reverse FK → c1)
client.delete(f"/db/api/{c1}")
def test_link_pages_via_relation(client):
"""Link two pages via a relation and verify reverse."""
r1 = client.post("/db/api", json={"name": "A"})
r2 = client.post("/db/api", json={"name": "B"})
c1, c2 = r1.json()["id"], r2.json()["id"]
rel = client.post(f"/db/{c1}/properties/relation", json={
"name": "Items", "related_collection_id": c2, "reverse_name": "Parent",
})
prop_id = rel.json()["id"]
p1 = client.post(f"/db/{c1}/pages/api", json={"title": "Page A"})
p2 = client.post(f"/db/{c2}/pages/api", json={"title": "Page B"})
pid1, pid2 = p1.json()["id"], p2.json()["id"]
resp = client.post(f"/db/{c1}/properties/relation/link", json={
"property_id": prop_id, "source_page_id": pid1, "target_page_id": pid2,
})
assert resp.status_code == 200
src = client.get(f"/db/pages/{pid1}/api").json()
props = json.loads(src["property_values_json"])
assert pid2 in props.get(str(prop_id), [])
tgt = client.get(f"/db/pages/{pid2}/api").json()
tprops = json.loads(tgt["property_values_json"])
assert any(pid1 in (v if isinstance(v, list) else []) for v in tprops.values())
client.delete(f"/db/api/{c2}") # c2 first
client.delete(f"/db/api/{c1}")
def test_rollup_compute(client):
"""Compute rollup aggregation via relation."""
r1 = client.post("/db/api", json={"name": "Proj"})
r2 = client.post("/db/api", json={"name": "Task"})
c1, c2 = r1.json()["id"], r2.json()["id"]
rel = client.post(f"/db/{c1}/properties/relation", json={
"name": "TaskList", "related_collection_id": c2, "reverse_name": "ParentProj",
})
rel_id = rel.json()["id"]
num = client.post(f"/db/{c2}/properties/api", json={"name": "Hours", "prop_type": "number"})
num_id = num.json()["id"]
proj = client.post(f"/db/{c1}/pages/api", json={"title": "Proj1"})
pid = proj.json()["id"]
t1 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 1", "properties": {str(num_id): 5}})
t2 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 2", "properties": {str(num_id): 10}})
client.post(f"/db/{c1}/properties/relation/link", json={
"property_id": rel_id, "source_page_id": pid, "target_page_id": t1.json()["id"],
})
client.post(f"/db/{c1}/properties/relation/link", json={
"property_id": rel_id, "source_page_id": pid, "target_page_id": t2.json()["id"],
})
resp = client.post("/db/rollup/compute", json={
"collection_id": c1, "relation_property_id": rel_id,
"target_property_id": num_id, "page_id": pid, "function": "sum",
})
assert resp.status_code == 200
assert resp.json()["result"] == 15.0
resp2 = client.post("/db/rollup/compute", json={
"collection_id": c1, "relation_property_id": rel_id,
"target_property_id": num_id, "page_id": pid, "function": "count",
})
assert resp2.json()["result"] == 2
client.delete(f"/db/api/{c2}") # c2 first
client.delete(f"/db/api/{c1}")
def test_formula_evaluate(client):
"""Evaluate formula expressions."""
# Function-based expressions work
resp = client.post("/db/formula/evaluate", json={
"expression": "round(3.14159, 2)", "context": {},
})
assert resp.status_code == 200
assert resp.json()["result"] == 3.14
resp = client.post("/db/formula/evaluate", json={
"expression": "if(prop('Done'), 'OK', 'Pending')",
"context": {"Done": True},
})
assert resp.json()["result"] == "OK"
resp = client.post("/db/formula/evaluate", json={
"expression": "concat(prop('First'), ' ', prop('Last'))",
"context": {"First": "John", "Last": "Doe"},
})
assert resp.json()["result"] == "John Doe"
resp = client.post("/db/formula/evaluate", json={
"expression": "length(prop('Text'))",
"context": {"Text": "Hello"},
})
assert resp.json()["result"] == 5
resp = client.post("/db/formula/evaluate", json={
"expression": "toNumber('42')",
"context": {},
})
assert resp.json()["result"] == 42.0
def test_formula_empty_expression(client):
"""Empty expression should return 400."""
resp = client.post("/db/formula/evaluate", json={"expression": "", "context": {}})
assert resp.status_code == 400
# ── v2.1.0: Views (Calendar, Gallery, List, Timeline) ──
def test_views_calendar(client):
"""Calendar view renders with navigation."""
r = client.post("/db/api", json={"name": "Cal DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Event 1", "properties": {"date": "2026-07-15"}})
resp = client.get(f"/db/{cid}/view/calendar")
assert resp.status_code == 200
assert "July" in resp.text or "juillet" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_gallery(client):
"""Gallery view renders card grid."""
r = client.post("/db/api", json={"name": "Gal DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Card 1"})
resp = client.get(f"/db/{cid}/view/gallery")
assert resp.status_code == 200
assert "gallery" in resp.text.lower() or "gal-card" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_list(client):
"""List view renders compact items."""
r = client.post("/db/api", json={"name": "List DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Item 1"})
resp = client.get(f"/db/{cid}/view/list")
assert resp.status_code == 200
assert "list-item" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_timeline(client):
"""Timeline view renders date bars."""
r = client.post("/db/api", json={"name": "TL DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"date": "2026-07-01...2026-07-15"}})
resp = client.get(f"/db/{cid}/view/timeline")
assert resp.status_code == 200
assert "tl-bar" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_default_table(client):
"""Default view renders as table."""
r = client.post("/db/api", json={"name": "Tab DB"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}")
assert resp.status_code == 200
assert "<table>" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_calendar_navigation(client):
"""Calendar supports month navigation via query params."""
r = client.post("/db/api", json={"name": "Nav DB"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}/view/calendar?year=2026&month=8")
assert resp.status_code == 200
assert "August" in resp.text or "août" in resp.text
client.delete(f"/db/api/{cid}")
# ── v1.7.0: View Management ──
def test_view_config_update(client):
"""Update view config (card_size, group_by)."""
r = client.post("/db/api", json={"name": "V Config"})
cid = r.json()["id"]
views = client.get(f"/db/{cid}/views/api").json()["views"]
vid = views[0]["id"]
resp = client.put(f"/db/views/{vid}/config", json={
"card_size": "large", "group_by": "Status",
})
assert resp.status_code == 200
assert resp.json()["config"]["card_size"] == "large"
client.delete(f"/db/api/{cid}")
def test_save_view_as(client):
"""Save current state as new view."""
r = client.post("/db/api", json={"name": "Save As"})
cid = r.json()["id"]
resp = client.post(f"/db/{cid}/views/save-as", json={
"name": "My Kanban", "view_type": "board",
"config": {"group_by": "Priority"},
})
assert resp.status_code == 200
assert resp.json()["name"] == "My Kanban"
views = client.get(f"/db/{cid}/views/api").json()["views"]
assert len(views) == 2
client.delete(f"/db/api/{cid}")
def test_list_views(client):
"""List views for a collection."""
r = client.post("/db/api", json={"name": "V List"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}/views/api")
assert resp.status_code == 200
assert len(resp.json()["views"]) == 1 # default view
client.delete(f"/db/api/{cid}")
# ── v2.1.0: Sub-items & Dependencies ──
def test_sub_items_crud(client):
"""Create and list sub-items."""
r = client.post("/db/api", json={"name": "Sub DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent Task"})
pid = p.json()["id"]
# Create sub-item
resp = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 1"})
assert resp.status_code == 200
assert resp.json()["parent_id"] == pid
resp2 = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 2"})
assert resp2.status_code == 200
# List
items = client.get(f"/db/{cid}/pages/{pid}/sub-items").json()["sub_items"]
assert len(items) == 2
client.delete(f"/db/api/{cid}")
def test_status_aggregate(client):
"""Aggregate child statuses."""
r = client.post("/db/api", json={"name": "Agg DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent", "properties": {"Status": "In Progress"}})
pid = p.json()["id"]
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C1", "properties": {"Status": "Done"}})
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C2", "properties": {"Status": "In Progress"}})
resp = client.get(f"/db/{cid}/pages/{pid}/status-aggregate")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert data["done"] == 1
assert data["all_done"] is False
client.delete(f"/db/api/{cid}")
def test_dependencies_check(client):
"""Dependency constraint check."""
r = client.post("/db/api", json={"name": "Dep DB"})
cid = r.json()["id"]
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Done"}})
aid, bid = a.json()["id"], b.json()["id"]
# A blocks B
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
# Check if A can go to Done (should not, B is Done but blocks is on A, wait...)
# B is Done, so A CAN transition
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
assert resp.status_code == 200
assert resp.json()["can_transition"] is True
assert resp.json()["blocked_by"] == []
client.delete(f"/db/api/{cid}")
def test_dependencies_blocked(client):
"""Dependency blocks transition when blocker is not done."""
r = client.post("/db/api", json={"name": "Block DB"})
cid = r.json()["id"]
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Todo"}})
aid, bid = a.json()["id"], b.json()["id"]
# A blocks B — A depends on B being done
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
# B is Todo, so A CANNOT transition to Done
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
assert resp.json()["can_transition"] is False
assert len(resp.json()["blocked_by"]) == 1
client.delete(f"/db/api/{cid}")
# ── v2.1.0: My Tasks ──
def test_my_tasks_page(client):
"""My Tasks dashboard renders."""
resp = client.get("/my-tasks")
assert resp.status_code == 200
assert "My Tasks" in resp.text
def test_my_tasks_api(client):
"""My Tasks API returns JSON."""
r = client.post("/db/api", json={"name": "My Project"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Task 1", "properties": {"Status": "Todo"}})
client.post(f"/db/{cid}/pages/api", json={"title": "Task 2", "properties": {"Status": "In Progress"}})
resp = client.get("/my-tasks/api")
assert resp.status_code == 200
data = resp.json()
assert "tasks" in data
assert data["total"] >= 2
client.delete(f"/db/api/{cid}")
def test_my_tasks_view_today(client):
resp = client.get("/my-tasks?view=today")
assert resp.status_code == 200
def test_my_tasks_view_overdue(client):
resp = client.get("/my-tasks?view=overdue")
assert resp.status_code == 200
# ── v2.1.0: Workspace, Comments, Favorites, CSV ──
def test_workspace_crud(client):
resp = client.post("/workspace", json={"name": "Team WS"})
assert resp.status_code == 200
ws_id = resp.json()["id"]
members = client.get(f"/workspace/{ws_id}/members")
assert len(members.json()["members"]) >= 1
# cleanup
with client as _:
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
conn.commit()
def test_comments_crud(client):
r = client.post("/db/api", json={"name": "Comment DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Discuss"})
pid = p.json()["id"]
resp = client.post(f"/workspace/pages/{pid}/comments", json={"body": "Nice work!"})
assert resp.status_code == 200
comments = client.get(f"/workspace/pages/{pid}/comments").json()["comments"]
assert len(comments) == 1
assert comments[0]["body"] == "Nice work!"
client.delete(f"/db/api/{cid}")
def test_favorites_crud(client):
"""Test favorites CRUD for sidebar pages — POST/DELETE /board/api/favorites/{page_id}."""
# Create a page first
r = client.post("/board/api/pages?section=Private&project=test/test")
assert r.status_code == 200
pid = r.json()["id"]
# Add to favorites
resp = client.post(f"/board/api/favorites/{pid}")
assert resp.status_code == 200
assert resp.json()["status"] == "added"
# List favorites
favs = client.get("/board/api/favorites").json()["favorites"]
assert pid in favs
# Remove from favorites
resp = client.delete(f"/board/api/favorites/{pid}")
assert resp.status_code == 200
assert resp.json()["status"] == "removed"
# List should be empty
favs = client.get("/board/api/favorites").json()["favorites"]
assert pid not in favs
def test_csv_import_export(client):
r = client.post("/db/api", json={"name": "CSV DB"})
cid = r.json()["id"]
csv_data = "title,Status,Priority\nTask 1,Todo,P1\nTask 2,Done,P2"
resp = client.post(f"/workspace/collections/{cid}/import/csv", json={"csv": csv_data})
assert resp.status_code == 200
assert resp.json()["imported"] == 2
export = client.get(f"/workspace/collections/{cid}/export/csv")
assert export.status_code == 200
assert "Task 1" in export.text
client.delete(f"/db/api/{cid}")
def test_public_view(client):
r = client.post("/db/api", json={"name": "Public DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Public Page"})
resp = client.get(f"/workspace/public/{cid}")
assert resp.status_code == 200
assert "Public Page" in resp.text
client.delete(f"/db/api/{cid}")
def test_db_templates(client):
resp = client.post("/workspace/templates/database", json={
"name": "Bug Tracker", "schema": [{"name": "Severity", "type": "select"}],
})
assert resp.status_code == 200
tid = resp.json()["id"]
templates = client.get("/workspace/templates/database").json()["templates"]
assert len(templates) >= 1
apply = client.post(f"/workspace/templates/database/{tid}/apply", json={"name": "Bugs v2"})
assert apply.status_code == 200
client.delete(f"/db/api/{apply.json()['collection_id']}")
def test_page_history(client):
r = client.post("/db/api", json={"name": "Hist DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "History Page"})
pid = p.json()["id"]
client.post(f"/workspace/pages/{pid}/history", json={
"change_type": "created", "snapshot": {"title": "History Page"},
})
hist = client.get(f"/workspace/pages/{pid}/history").json()["history"]
assert len(hist) == 1
client.delete(f"/db/api/{cid}")
# ── v2.1.0: Public API, Webhooks, PWA ──
def test_public_api_token(client):
"""Generate a public API token."""
resp = client.post("/api/v1/token")
assert resp.status_code == 200
token = resp.json()["token"]
assert token.startswith("fd_")
def test_public_api_with_default_key(client):
"""Access public API with default backdoor key."""
headers = {"Authorization": "Bearer fd-public-key"}
r = client.post("/db/api", json={"name": "API DB"})
cid = r.json()["id"]
resp = client.get("/api/v1/collections", headers=headers)
assert resp.status_code == 200
client.delete(f"/db/api/{cid}")
def test_public_api_unauthorized(client):
"""Public API rejects missing token."""
resp = client.get("/api/v1/collections")
assert resp.status_code == 401
def test_public_api_pages(client):
"""Access public pages API with default key."""
headers = {"Authorization": "Bearer fd-public-key"}
r = client.post("/db/api", json={"name": "API DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "API Page"})
resp = client.get(f"/api/v1/collections/{cid}/pages", headers=headers)
assert resp.status_code == 200
client.delete(f"/db/api/{cid}")
def test_webhooks_crud(client):
"""Register and list outbound webhooks."""
resp = client.post("/workspace/webhooks", json={"url": "https://example.com/hook", "event": "page.created"})
assert resp.status_code == 200
wh_id = resp.json()["id"]
hooks = client.get("/workspace/webhooks").json()["webhooks"]
assert len(hooks) >= 1
client.delete(f"/workspace/webhooks/{wh_id}")
assert len(client.get("/workspace/webhooks").json()["webhooks"]) == 0
def test_pwa_manifest(client):
resp = client.get("/manifest.json")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "FlowDeck"
assert data["display"] == "standalone"