"""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("/") assert resp.status_code == 200 assert "sidebar" in resp.text assert "workspace" in resp.text or "app-layout" in resp.text 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 == 200 # Notion-style elements assert "sidebar" in resp.text or "main-wrapper" in resp.text or "topbar" in resp.text # ── 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 "" 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): r = client.post("/db/api", json={"name": "Fav DB"}) cid = r.json()["id"] resp = client.post("/workspace/favorites", json={"collection_id": cid}) assert resp.status_code == 200 favs = client.get("/workspace/favorites").json()["favorites"] assert len(favs) >= 1 fav_id = favs[0]["id"] client.delete(f"/workspace/favorites/{fav_id}") assert len(client.get("/workspace/favorites").json()["favorites"]) == 0 client.delete(f"/db/api/{cid}") 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"