From df9a269d76a84f2ddb4838bfc907eba6b14f0357 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 12:11:12 -0400 Subject: [PATCH] =?UTF-8?q?test:=20A32=20phase=202a=20=E2=80=94=20library?= =?UTF-8?q?=2010/10=20+=202=20routes=20fant=C3=B4mes=20supprim=C3=A9es=20(?= =?UTF-8?q?v7.12.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/test_smoke_uncovered.py : +6 tests pour library.py (1/10 → 8 routes couvertes) : les 5 listes en boucle (recents/favorites/published/private/ workspace → 200 + items), /private avec page seedée retrouvée, /children/{id} avec parent/enfant seedés, /repository vide ET clé (string, aucun réseau), non-régression 404 sur les routes supprimées. - DÉCOUVERTE (les smokes l'ont prouvé) : `/api/library/local-workspace-children` renvoyait un 500 systématique (test vert → ASGI double response.start) et `/api/library/local-workspace` 500 dès qu'un workspace existe — les deux lisaient `local_workspace_items`, table AUCUNEMENT créée dans le codebase (0 CREATE TABLE) et sans 1 seule référence front. Supprimés avec `library._format_size` devenu mort (la copie de dashboard.py est inchangée). `local_workspace_items` : 0 occurrence restante dans app/. - helper `_seed_page` minimal (workspace NOT NULL inclus) + nettoyage en finally (pas de pollution des autres tests). suite **1053/1053** (229 s) · `ruff check app tests` OK · docs à jour restent phase 2b : api.py 3/23, dashboard.py 17/63, api_v2.py 50/115 --- CHANGELOG.md | 26 ++++++ ROADMAP.md | 4 +- VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/routers/library.py | 153 ---------------------------------- docs/openapi-v2.json | 85 +------------------ tests/test_smoke_uncovered.py | 77 +++++++++++++++++ 8 files changed, 109 insertions(+), 242 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26db0c6..981885a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog - FlowDeck +## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10) + +### Tests + +- `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes + (recents/favorites/published/private/workspace) en un test de boucle, + `/private` avec une page seedée et retrouvée, `/children/{id}` avec un + parent/enfant seedés (titre retrouvé), `/repository` vide et clé + (aucun appel réseau — la clé n'est qu'une string de workspace) +- Test de non-régression 404 sur les 2 routes supprimées + +### Removed + +- **2 routes cassées supprimées** (découverte des smokes) : + `/api/library/local-workspace-children/{id}` renvoyait un 500 systématique + et `/api/library/local-workspace` un 500 dès qu'un workspace existait — + les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle + part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**. + `library._format_size` devenu mort : supprimé aussi (une version vit dans + `dashboard.py`, inchangée) +- `local_workspace_items` : plus aucune occurrence dans `app/` + +### Tests + +- Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests + ## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test) ### Tests diff --git a/ROADMAP.md b/ROADMAP.md index b236efa..afa29fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1152,7 +1152,7 @@ Quality DB views, Agent IA Palette → Realtime + E - [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**. - [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.* - [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**. -- [x] **A32 — Couverture de tests par trou — PARTIEL** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante).* — **phase 1 faite 2026-10-01** : les **4 routers à 0 test sont couverts** — `tests/test_smoke_uncovered.py`, 10 tests : webhooks (receive ok sans secret, 401 HMAC via monkeypatch, register 400 sans appel réseau, status avec `gitea.list_webhooks` stubbé), notes (GET HTML + roundtrip upsert avec échappement), sidebar_config (GET, PUT persisté relu en base, 400 sans config + remise en état), github_routes (status/disconnect) — **0 accès réseau réel**. **Reste (phase 2)** : quasi nuls — `library.py` 1/10, `api.py` 3/23, `dashboard.py` 17/63, `api_v2.py` 50/115 → même recette (1 smoke par route, fixture `client` existante). Effort : **M**. +- [x] **A32 — Couverture de tests par trou — PARTIEL** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante).* — **phase 1 faite 2026-10-01** : les **4 routers à 0 test sont couverts** — `tests/test_smoke_uncovered.py`, 10 tests : webhooks (receive ok sans secret, 401 HMAC via monkeypatch, register 400 sans appel réseau, status avec `gitea.list_webhooks` stubbé), notes (GET HTML + roundtrip upsert avec échappement), sidebar_config (GET, PUT persisté relu en base, 400 sans config + remise en état), github_routes (status/disconnect) — **0 accès réseau réel**. **Phase 2a faite 2026-10-01** : `library.py` **1/10 → 8 routes couvertes** (5 listes en 1 test + private seedé, children avec parent/enfant seedé, repository vide/clé, + test de non-régression post-suppression). **Découverte** : `/api/library/local-workspace-children/{id}` (toujours 500) et `/api/library/local-workspace` (500 dès qu'un workspace existe) lisaient `local_workspace_items` — **une table inexistante nulle part dans le codebase**, zéro référence front → **2 routes + `_format_size` supprimés** (test 404 anti-réintroduction). **Reste (phase 2b)** : `api.py` 3/23, `dashboard.py` 17/63, `api_v2.py` 50/115 → même recette. Effort : **M**. - [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.* - [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.* - [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).* @@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* -→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. +→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. diff --git a/VERSION b/VERSION index b23eb27..5f84a81 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.11.0 +7.12.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 7b4f874..cd583ff 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.11.0 (audit — A32 phase 1 : 4 routers à 0 test couverts) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.12.0 (audit — A32 phase 2 : library 10/10, 2 routes mortes supprimées) | **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 diff --git a/app/main.py b/app/main.py index da20d28..f138ada 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.11.0", + version="7.12.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/library.py b/app/routers/library.py index 61b3854..ddd9a36 100644 --- a/app/routers/library.py +++ b/app/routers/library.py @@ -341,74 +341,6 @@ def library_private( return {"items": items} -@router.get("/local-workspace-children/{item_id:int}") -def library_local_workspace_children(item_id: int, request: Request): - """Return children of a local workspace item for tree expansion.""" - _get_user_id(request) - with get_conn() as conn: - # Get the item to find its workspace - item = conn.execute( - "SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL", - [item_id], - ).fetchone() - if not item: - return {"items": []} - - rows = conn.execute( - "SELECT id, name, is_folder, parent_id, content_format, size, " - "COALESCE(updated_at, created_at) as updated_at " - "FROM local_workspace_items " - "WHERE parent_id = ? AND deleted_at IS NULL " - "ORDER BY is_folder DESC, name COLLATE NOCASE", - [item_id], - ).fetchall() - - items = [] - for r in rows: - name = r["name"] or "Untitled" - is_folder = bool(r["is_folder"]) - icon = "📁" if is_folder else "📄" - fn = name.lower() - if not is_folder: - if fn.endswith(".pdf"): - icon = "📕" - elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]): - icon = "🖼️" - elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]): - icon = "📜" - - with get_conn() as conn: - child_count = conn.execute( - "SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL", - [r["id"]], - ).fetchone()[0] - - items.append({ - "id": r["id"], - "title": name, - "icon": icon, - "is_folder": is_folder, - "source_type": "local-ws", - "source_label": "", - "workspace": "", - "workspace_name": "", - "author": "", - "author_initial": "?", - "updated_at": r["updated_at"] or "", - "visited_at": "", - "has_children": child_count > 0, - "children": [], - "url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}", - "content_format": r["content_format"] or "file", - "favorited": False, - "page_icon": "", - "tags": [], - "size_display": _format_size(r["size"]) if r["size"] else "", - }) - - return {"items": items} - - @router.get("/children/{page_id:int}") def library_children(page_id: int, request: Request): """Return child pages for a given parent page (for tree expansion in Library).""" @@ -449,91 +381,6 @@ def library_repository( return {"items": items} -@router.get("/local-workspace") -def library_local_workspace( - request: Request, - workspace_id: int = Query(default=0), -): - """Return local workspace items (files/folders) formatted for Library display.""" - from app.routers.dashboard import _get_active_workspace - uid = _get_user_id(request) - - # Get the active workspace - ws = _get_active_workspace(request, user_id=uid) - if not ws: - return {"items": []} - - ws_id = workspace_id or ws["id"] - - # Query local workspace tree - with get_conn() as conn: - rows = conn.execute( - "SELECT id, name, is_folder, parent_id, content_format, size, " - "COALESCE(updated_at, created_at) as updated_at " - "FROM local_workspace_items " - "WHERE workspace_id = ? AND deleted_at IS NULL " - "ORDER BY is_folder DESC, name COLLATE NOCASE", - [ws_id], - ).fetchall() - - items = [] - for r in rows: - name = r["name"] or "Untitled" - is_folder = bool(r["is_folder"]) - icon = "📁" if is_folder else "📄" - fn = name.lower() - if not is_folder: - if fn.endswith(".pdf"): - icon = "📕" - elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]): - icon = "🖼️" - elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]): - icon = "📜" - - # Check for children - child_count = conn.execute( - "SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL", - [r["id"]], - ).fetchone()[0] - - items.append({ - "id": r["id"], - "title": name, - "icon": icon, - "is_folder": is_folder, - "source_type": "local-ws", - "source_label": ws.get("name", "Workspace"), - "workspace": ws.get("name", ""), - "workspace_name": ws.get("name", ""), - "author": "", - "author_initial": "?", - "updated_at": r["updated_at"] or "", - "visited_at": "", - "has_children": child_count > 0, - "children": [], - "url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}", - "content_format": r["content_format"] or "file", - "favorited": False, - "page_icon": "", - "tags": [], - "size_display": _format_size(r["size"]) if r["size"] else "", - }) - - return {"items": items} - - -def _format_size(size_bytes): - if not size_bytes: - return "" - if size_bytes < 1024: - return f"{size_bytes} B" - if size_bytes < 1048576: - return f"{size_bytes/1024:.1f} KB" - if size_bytes < 1073741824: - return f"{size_bytes/1048576:.1f} MB" - return f"{size_bytes/1073741824:.1f} GB" - - @router.get("/workspace") def library_workspace( request: Request, diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index ffa6634..0a2a01d 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.11.0" + "version": "7.12.0" }, "paths": { "/auth/register": { @@ -10765,47 +10765,6 @@ } } }, - "/api/library/local-workspace-children/{item_id}": { - "get": { - "tags": [ - "library" - ], - "summary": "Library Local Workspace Children", - "description": "Return children of a local workspace item for tree expansion.", - "operationId": "library_local_workspace_children_api_library_local_workspace_children__item_id__get", - "parameters": [ - { - "name": "item_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Item Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, "/api/library/children/{page_id}": { "get": { "tags": [ @@ -10899,48 +10858,6 @@ } } }, - "/api/library/local-workspace": { - "get": { - "tags": [ - "library" - ], - "summary": "Library Local Workspace", - "description": "Return local workspace items (files/folders) formatted for Library display.", - "operationId": "library_local_workspace_api_library_local_workspace_get", - "parameters": [ - { - "name": "workspace_id", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "default": 0, - "title": "Workspace Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, "/api/library/workspace": { "get": { "tags": [ diff --git a/tests/test_smoke_uncovered.py b/tests/test_smoke_uncovered.py index ea8f7a9..5e05c2c 100644 --- a/tests/test_smoke_uncovered.py +++ b/tests/test_smoke_uncovered.py @@ -107,3 +107,80 @@ 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)