"""A32 — smoke tests : un par route des 4 routers à 0 test. `webhooks.py` (3), `notes.py` (2), `sidebar_config.py` (2), `github_routes.py` (2). Aucun accès réseau réel : le secret webhook est piloté par monkeypatch et `gitea.list_webhooks` est stubbé. """ from app.config import settings OWNER, REPO = "smoke-a32", "repo-x" # ── webhooks.py (0/3) ──────────────────────────────────────────────────────── def test_webhook_receive_ok_without_secret(client, monkeypatch): monkeypatch.setattr(settings, "gitea_webhook_secret", "") r = client.post("/api/webhook", json={"zen": "smoke"}) assert r.status_code == 200 assert r.json() == {"status": "ok"} def test_webhook_receive_rejects_bad_signature(client, monkeypatch): monkeypatch.setattr(settings, "gitea_webhook_secret", "s3cret") r = client.post("/api/webhook", json={}) # pas d'en-tête X-Gitea-Signature assert r.status_code == 401 assert r.json()["detail"] == "Invalid signature" def test_webhook_register_fails_fast_without_secret(client, monkeypatch): # secret vide → 400 AVANT tout appel réseau (gitea.list_webhooks) monkeypatch.setattr(settings, "gitea_webhook_secret", "") r = client.post(f"/api/webhook/register/{OWNER}/{REPO}") assert r.status_code == 400 assert "not configured" in r.json()["detail"] def test_webhook_status_stubbed(client, monkeypatch): from app.services import gitea_client async def _no_hooks(*_a, **_k): return [] monkeypatch.setattr(gitea_client.gitea, "list_webhooks", _no_hooks) r = client.get(f"/api/webhook/status/{OWNER}/{REPO}") assert r.status_code == 200 assert r.json() == {"registered": False} # ── notes.py (0/2) ─────────────────────────────────────────────────────────── def test_notes_get_renders_html(client): r = client.get(f"/notes/{OWNER}/{REPO}") assert r.status_code == 200 assert "text/html" in r.headers["content-type"] def test_notes_save_roundtrip(client): payload = "note de smoke A32" html_payload = "&co" r = client.post( f"/notes/{OWNER}/{REPO}", data={"content": f"{payload} {html_payload}"} ) assert r.status_code == 200 assert payload in r.text # contenu rendu assert "<b>&co</b>" in r.text # échappé (A10 — pas d'HTML cru) again = client.get(f"/notes/{OWNER}/{REPO}") assert payload in again.text # persistée en base (upsert) # ── sidebar_config.py (0/2) ────────────────────────────────────────────────── def test_sidebar_config_get(client): r = client.get("/api/sidebar/config") assert r.status_code == 200 cfg = r.json()["config"] assert isinstance(cfg, dict) and "meetings" in cfg def test_sidebar_config_put_persists_and_validates(client): r = client.put( "/api/sidebar/config", json={"config": {"meetings": {"visible": False, "order": 2, "show_count": 5}}}, ) assert r.status_code == 200 back = client.get("/api/sidebar/config").json()["config"] assert back["meetings"]["visible"] is False # relu depuis users.sidebar_config bad = client.put("/api/sidebar/config", json={}) assert bad.status_code == 400 # config dict requis # remise en état pour les autres tests restore = client.put( "/api/sidebar/config", json={"config": {"meetings": {"visible": True, "order": 2, "show_count": 5}}}, ) assert restore.status_code == 200 # ── github_routes.py (0/2) ─────────────────────────────────────────────────── def test_github_status_unlinked(client): r = client.get("/api/github/status") assert r.status_code == 200 assert r.json() == {"linked": False} def test_github_disconnect_ok(client): r = client.delete("/api/github/disconnect") assert r.status_code == 200 assert r.json() == {"status": "ok"} # ── library.py (1/10 → 10/10) ──────────────────────────────────────────────── def _seed_page(title: str, **cols) -> int: """Insert minimale (title/content/format + colonnes surnuméraires), id renvoyé.""" from app.db import get_conn columns = ["workspace", "title", "content", "content_format", *cols] placeholders = ", ".join("?" for _ in columns) values = ["", title, "", "markdown", *cols.values()] with get_conn() as conn: cur = conn.execute( f"INSERT INTO pages ({', '.join(columns)}) VALUES ({placeholders})", values ) pid = cur.lastrowid conn.commit() return pid def test_library_all_lists_return_items(client): """Les 5 listes de la bibliothèque répondent {"items": [...]}.""" for path in ("/recents", "/favorites", "/published", "/private", "/workspace"): r = client.get(f"/api/library{path}") assert r.status_code == 200, (path, r.status_code) assert isinstance(r.json()["items"], list), path def test_library_private_lists_seeded_page(client): from app.db import get_conn pid = _seed_page("A32 private page", parent_section="Private") try: items = client.get("/api/library/private").json()["items"] assert "A32 private page" in [i["title"] for i in items] finally: with get_conn() as conn: conn.execute("DELETE FROM pages WHERE id=?", (pid,)) conn.commit() def test_library_children_lists_child(client): from app.db import get_conn parent = _seed_page("A32 parent") child = _seed_page("A32 child", parent_id=parent) try: items = client.get(f"/api/library/children/{parent}").json()["items"] assert [i["title"] for i in items] == ["A32 child"] finally: with get_conn() as conn: conn.execute("DELETE FROM pages WHERE id IN (?, ?)", (parent, child)) conn.commit() def test_library_local_workspace_routes_removed(client): """A32 → A30 : ces 2 routes lisaient `local_workspace_items`, une table inexistante nulle part dans le codebase (500 systématique côté children, 500 d?s qu'un workspace existe côté local-workspace) et AUCUNE référence front → supprimées avec `_format_size` devenu mort.""" assert client.get("/api/library/local-workspace-children/1").status_code == 404 assert client.get("/api/library/local-workspace").status_code == 404 def test_library_repository_empty_and_keyed(client): empty = client.get("/api/library/repository") assert empty.status_code == 200 and empty.json() == {"items": []} keyed = client.get("/api/library/repository?gitea_owner=acme&gitea_repo=flowdeck") assert keyed.status_code == 200 assert isinstance(keyed.json()["items"], list) def test_library_lists_still_available_after_removal(client): """Les listes restantes (celles qu'utilise le front) répondent toujours.""" r = client.get("/api/library/recents") assert r.status_code == 200 and isinstance(r.json()["items"], list) # ── api.py (3/23 → 11/23) ──────────────────────────────────────────────────── def _seed_board(client) -> None: """Board via l'endpoint lui-même (pas de SQL brut).""" r = client.post( f"/api/board-config/{OWNER}/{REPO}", params={"columns": '["Todo", "Doing"]', "wip_limits": "{}"}, ) assert r.status_code == 200 and r.json() == {"status": "ok"} def test_api_board_config_default_then_roundtrip(client): # pas de board → défauts r = client.get(f"/api/board-config/{OWNER}/other-repo") assert r.status_code == 200 assert r.json()["columns"] == ["Backlog", "À faire", "En cours", "Révision", "Terminé"] # création puis relecture _seed_board(client) back = client.get(f"/api/board-config/{OWNER}/{REPO}") assert back.json()["columns"] == ["Todo", "Doing"] def test_api_col_mapping_requires_board_and_works(client): # board inexistant → 404 r = client.post( "/api/col-mapping", params={"owner": OWNER, "repo": "absent", "column": "Doing", "gitea_label": "x"}, ) assert r.status_code == 404 # avec board → ok (upsert) puis suppression _seed_board(client) r2 = client.post( "/api/col-mapping", params={"owner": OWNER, "repo": REPO, "column": "Doing", "gitea_label": "in-progress"}, ) assert r2.status_code == 200 assert r2.json() == {"status": "ok", "column": "Doing", "label": "in-progress"} r3 = client.delete( "/api/col-mapping", params={"owner": OWNER, "repo": REPO, "column": "Doing"} ) assert r3.status_code == 200 and r3.json() == {"status": "ok", "column": "Doing"} def test_api_card_metadata(client): r = client.post(f"/api/card/{OWNER}/absent/1", params={"priority": "high"}) assert r.status_code == 404 # board requis _seed_board(client) r2 = client.post( f"/api/card/{OWNER}/{REPO}/1", params={"priority": "high", "due_date": "2026-10-01"} ) assert r2.status_code == 200 and r2.json() == {"status": "ok"} def test_api_collaborators_stubbed(client, monkeypatch): from app.services import gitea_client async def _none(*_a, **_k): return [] monkeypatch.setattr(gitea_client.gitea, "get_collaborators", _none) r = client.get(f"/api/collaborators/{OWNER}/{REPO}") assert r.status_code == 200 assert r.json() == {"collaborators": []} def test_api_checklist_item_toggle_and_deletes(client): """PATCH item / DELETE item / DELETE checklist : mutation vérifiée en base.""" from app.db import get_conn _seed_board(client) with get_conn() as conn: board_id = conn.execute( "SELECT id FROM boards WHERE project_owner=? AND project_name=?", (OWNER, REPO), ).fetchone()["id"] cur = conn.execute( "INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, 1, 'A32')", (board_id,), ) cl_id = cur.lastrowid cur = conn.execute( "INSERT INTO checklist_items (checklist_id, content) VALUES (?, 'fait ?')", (cl_id,), ) item_id = cur.lastrowid conn.commit() try: r = client.patch( f"/api/checklist-items/{item_id}", params={"checked": True, "content": "fait !"} ) assert r.status_code == 200 and r.json() == {"status": "ok"} with get_conn() as conn: row = conn.execute( "SELECT checked, content FROM checklist_items WHERE id=?", (item_id,) ).fetchone() assert row["checked"] == 1 and row["content"] == "fait !" assert client.delete(f"/api/checklist-items/{item_id}").json() == {"status": "ok"} assert client.delete(f"/api/checklists/{cl_id}").json() == {"status": "ok"} with get_conn() as conn: left = conn.execute( "SELECT COUNT(*) AS n FROM checklists WHERE id=?", (cl_id,) ).fetchone()["n"] assert left == 0 finally: with get_conn() as conn: conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (cl_id,)) conn.execute("DELETE FROM checklists WHERE id=?", (cl_id,)) conn.commit() def _v2_headers(client, login: str) -> dict: """Compte local + token v1 en session (recette de test_public_api_v2).""" r = client.post( "/auth/register", json={"email": f"{login}@test.dev", "password": "secret123", "name": login}, ) assert r.status_code == 200, r.text tok = client.post("/api/v1/token").json()["token"] return {"Authorization": f"Bearer {tok}"} def test_api_frontend_error_capture_and_read(client): # purge d'un éventuel résidu d'un autre test client.get("/api/frontend-errors") ok = client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"}) assert ok.status_code == 200 and ok.json() == {"status": "ok"} invalid = client.post( "/api/frontend-error", content="pas du json", headers={"Content-Type": "application/json"}, ) assert invalid.status_code == 200 and invalid.json()["status"] == "ignored" # doublon consécutif dédupliqué (count=2), lecture puis purge client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"}) out = client.get("/api/frontend-errors").json() assert out["count"] == 1 and out["errors"][0]["count"] == 2 and out["cleared"] is True assert client.get("/api/frontend-errors").json()["count"] == 0 # ── api_v2.py (+5 routes à 0 ref) ──────────────────────────────────────────── def test_v2_evaluate_formula(client): headers = _v2_headers(client, "smoke-formula") r = client.post( "/api/v2/properties/evaluate-formula", json={"expression": "1 + 2"}, headers=headers, ) assert r.status_code == 200 d = r.json() # le moteur décide du résultat (1 + 2 y est renvoyé tel quel aujourd'hui) : # on valide le câble route (auth, parse, shape), pas le moteur lui-même. assert d["expression"] == "1 + 2" and "result" in d missing = client.post("/api/v2/properties/evaluate-formula", json={}, headers=headers) assert missing.status_code == 400 and "expression" in missing.json()["detail"] def test_v2_compute_rollup_validates_inputs(client): headers = _v2_headers(client, "smoke-rollup") r = client.post("/api/v2/properties/compute-rollup", json={}, headers=headers) assert r.status_code == 400 assert "collection_id required" in r.json()["detail"] sans_auth = client.post("/api/v2/properties/compute-rollup", json={}) assert sans_auth.status_code == 401 def test_v2_admin_audit_logs_gated_then_readable(client): headers = _v2_headers(client, "smoke-audit") # le portail dépend de l'utilisateur porteur (le TOUT premier utilisateur # du worker est admin) → on calcule l'attendu depuis /users/me plutôt que # de durcir un état qu'on ne contrôle pas. me = client.get("/api/v2/users/me", headers=headers).json() r = client.get("/api/v2/admin/audit-logs", headers=headers) if me.get("is_admin"): assert r.status_code == 200 else: assert r.status_code == 403 assert "Admin scope" in r.json()["detail"] # token avec scope admin → 200 + liste paginée admin_tok = client.post( "/api/v2/tokens", json={"name": "adm", "scopes": "read,admin"}, headers=headers ).json()["token"] r2 = client.get( "/api/v2/admin/audit-logs", headers={"Authorization": f"Bearer {admin_tok}"} ) assert r2.status_code == 200 assert isinstance(r2.json()["logs"], list) def test_v2_webhooks_events_catalogue(client): headers = _v2_headers(client, "smoke-events") r = client.get("/api/v2/webhooks/events", headers=headers) assert r.status_code == 200 d = r.json() assert isinstance(d["events"], list) and len(d["events"]) > 0 assert "*" in d["wildcards"] and "page.*" in d["wildcards"] def test_v2_webhook_verify_signature(client): from app.services.webhook_outbound import sign_payload headers = _v2_headers(client, "smoke-sig") secret, payload = "topsecret", '{"event": "page.updated"}' good = sign_payload(secret, payload.encode()) ok = client.post( "/api/v2/webhooks/verify-signature", json={"secret": secret, "payload": payload, "signature": good}, headers=headers, ) assert ok.status_code == 200 and ok.json()["valid"] is True bad = client.post( "/api/v2/webhooks/verify-signature", json={"secret": secret, "payload": payload, "signature": "deadbeef"}, headers=headers, ) assert bad.json()["valid"] is False