Scan strict des 115 routes api_v2.py contre tous les tests (chaîne de chemin littérale) → 5 routes sans AUCUNE référence, toutes couvertes maintenant : - POST /properties/evaluate-formula : 200 + shape, 400 sans expression. Le moteur renvoie « 1 + 2 » tel quel aujourd'hui → le smoke valide le câble (bearer, Body param, parse), pas le moteur (réalm de ses propres tests). - POST /properties/compute-rollup : 400 « collection_id required », 401 sans bearer. - GET /admin/audit-logs : portail admin VÉRIFIÉ — l'attendu est calculé depuis /users/me (le tout premier utilisateur d'un worker est admin : état non contrôlable depuis un test isolé), + token scope admin → 200 + logs. - GET /webhooks/events : catalogue non vide + wildcards * / page.*. - POST /webhooks/verify-signature : valid=True avec sign_payload() (le même helper que le serveur), False avec signature bidon. test_smoke_uncovered.py : 27 tests. Reste A32 : dashboard 17/63 + 6 routes gitea d'api.py (stub transport). suite **1064/1064** · `ruff check app tests` OK · docs à jour
405 lines
16 KiB
Python
405 lines
16 KiB
Python
"""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 = "<b>&co</b>"
|
|
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
|