Compare commits

...
2 Commits
Author SHA1 Message Date
bruno 360c705fd4 test: A32 phase 2d — dashboard +10 routes couvertes (v7.15.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Scan strict dashboard.py : 44 routes à 0 référence stricte sur 63. 10
couvertes cette passe (test_smoke_uncovered.py → 32 tests) :

- Tags CRUD complet : POST (nom lowercasé `SmokeTag` → `smoketag`), GET all
  (présent), PUT couleur (relue), DELETE (absente de la liste ensuite)
- Vie d'une page : GET /api/pages/{id}/content (contenu seedé relu) →
  PUT rename (ok + **400 titre vide** + titre relu en base) →
  POST trash (parent_section='Trash' + deleted_at RELUS en base) ;
  nettoyage en finally
- GET /api/sidebar/workspace-tree : 200 HTML, fragment « No pages yet »
  (pas de cookie workspace)
- POST /api/settings/avatar-color : couleur relue SUR L'UTILISATEUR DE LA
  SESSION (pas LIMIT 1), avatar_color/avatar_url d'origine restaurés
- GET /api/workspace/1/members : shape {"members": [...]}

Helper _seed_page : les colonnes par défaut sont surchargeables (content=)
pour les seeds à contenu.

Reste A32 : dashboard 34 routes à 0 ref (fichiers/avatars/imports…) +
6 routes Gitea d'api.py (stub transport httpx).

suite **1069/1069** · `ruff check app tests` OK · docs à jour
2026-10-01 13:36:56 -04:00
bruno 0698645dbd test: A32 phase 2c — api_v2 : les 5 routes à 0 ref couvertes (v7.14.0)
FlowDeck CI / lint (push) Successful in 1m52s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 34m59s
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
2026-10-01 13:09:32 -04:00
7 changed files with 235 additions and 8 deletions
+39
View File
@@ -1,5 +1,44 @@
# Changelog - FlowDeck
## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes)
### Tests
- Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10
couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) :
· **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence
dans la liste, changement de couleur relu, suppression puis absence
· **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename`
(ok + **400 sur titre vide** + titre relu en base) → POST `trash`
(`parent_section='Trash'` + `deleted_at` **relus en base**)
· `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie
· `settings/avatar-color` : couleur relue **sur l'utilisateur de la
session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally`
· `workspace/{id}/members` : shape `{"members": [...]}`
- Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …)
- Suite complète : **1069/1069**
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
### Tests
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
route/auth/parse, pas le moteur)
· `POST /properties/compute-rollup` : 400 `collection_id required`,
401 sans bearer
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
· `POST /webhooks/verify-signature` : **valid=True** avec
`sign_payload(secret, payload)` (même helper que le serveur), False avec
une signature bidon
- `test_smoke_uncovered.py` : 27 tests au total
- Suite complète : **1064/1064**
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
### Tests
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.13.0
7.15.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.13.0 (audit — A32 phase 2b : api.py 16/22 couvertes) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.15.0 (audit — A32 phase 2d : dashboard +10 routes) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.13.0",
version="7.15.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.13.0"
"version": "7.15.0"
},
"paths": {
"/auth/register": {
+190 -2
View File
@@ -116,9 +116,11 @@ 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]
base = {"workspace": "", "title": title, "content": "", "content_format": "markdown"}
base.update(cols) # surcharge (content=, parent_id=, parent_section=…)
columns = list(base)
placeholders = ", ".join("?" for _ in columns)
values = ["", title, "", "markdown", *cols.values()]
values = list(base.values())
with get_conn() as conn:
cur = conn.execute(
f"INSERT INTO pages ({', '.join(columns)}) VALUES ({placeholders})", values
@@ -297,6 +299,17 @@ def test_api_checklist_item_toggle_and_deletes(client):
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")
@@ -312,3 +325,178 @@ def test_api_frontend_error_capture_and_read(client):
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
# ── dashboard.py (17/63 → 27/63) ─────────────────────────────────────────────
def test_dashboard_tags_crud(client):
"""POST/GET/PUT/DELETE /api/settings/tags — cycle complet vérifié."""
r = client.post("/api/settings/tags", json={"name": "SmokeTag", "color": "#111111"})
assert r.status_code == 200
tag = r.json()["tag"]
assert tag["name"] == "smoketag" and tag["color"] == "#111111" # lowercasé
allr = client.get("/api/settings/tags/all").json()["tags"]
assert any(t["id"] == tag["id"] for t in allr)
assert client.put(
f"/api/settings/tags/{tag['id']}", json={"color": "#222222"}
).json() == {"status": "ok"}
allr2 = client.get("/api/settings/tags/all").json()["tags"]
mine = next(t for t in allr2 if t["id"] == tag["id"])
assert mine["color"] == "#222222"
assert client.delete(f"/api/settings/tags/{tag['id']}").json() == {"status": "ok"}
assert all(t["id"] != tag["id"] for t in client.get("/api/settings/tags/all").json()["tags"])
def test_dashboard_page_content_rename_trash(client):
"""GET content → PUT rename → POST trash : la vie d'une page vérifiée en base."""
from app.db import get_conn
pid = _seed_page("Page A32", content="contenu a32")
try:
r = client.get(f"/api/pages/{pid}/content")
assert r.status_code == 200
d = r.json()
assert d["title"] == "Page A32" and d["content"] == "contenu a32"
rr = client.put(f"/api/pages/{pid}/rename", json={"title": "Renommée A32"})
assert rr.status_code == 200 and rr.json() == {"status": "ok", "title": "Renommée A32"}
empty = client.put(f"/api/pages/{pid}/rename", json={"title": " "})
assert empty.status_code == 400 # titre vide refusé
rt = client.post(f"/api/pages/{pid}/trash")
assert rt.status_code == 200 and rt.json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute(
"SELECT title, parent_section, deleted_at FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["title"] == "Renommée A32"
assert row["parent_section"] == "Trash" and row["deleted_at"] is not None
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_dashboard_sidebar_workspace_tree(client):
r = client.get("/api/sidebar/workspace-tree")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
# pas de cookie workspace → fragment « No pages yet »
assert "No pages yet" in r.text
def test_dashboard_avatar_color_persisted(client):
"""POST avatar-color → couleur relue SUR L'UTILISATEUR DE LA SESSION."""
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
row = conn.execute(
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
).fetchone()
prev_color, prev_url = row["avatar_color"], row["avatar_url"]
try:
r = client.post("/api/settings/avatar-color", json={"color": "#112233"})
assert r.status_code == 200
assert r.json() == {"status": "ok", "color": "#112233"}
with get_conn() as conn:
row2 = conn.execute(
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
).fetchone()
assert row2["avatar_color"] == "#112233" and row2["avatar_url"] == ""
finally:
with get_conn() as conn:
conn.execute(
"UPDATE users SET avatar_color=?, avatar_url=? WHERE id=?",
(prev_color, prev_url, uid),
)
conn.commit()
def test_dashboard_workspace_members_list(client):
r = client.get("/api/workspace/1/members")
assert r.status_code == 200
assert isinstance(r.json()["members"], list)