Compare commits

...
2 Commits
Author SHA1 Message Date
bruno df9a269d76 test: A32 phase 2a — library 10/10 + 2 routes fantômes supprimées (v7.12.0)
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 8m41s
- 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
2026-10-01 12:11:12 -04:00
bruno da7326ffde test: A32 phase 1 — 4 routers à 0 test couverts (10 smokes) (v7.11.0)
FlowDeck CI / lint (push) Successful in 1m50s
FlowDeck CI / test (push) Successful in 13m55s
FlowDeck CI / docker (push) Canceled after 0s
tests/test_smoke_uncovered.py — un smoke par route des 4 routers qui n'avaient
AUCUN test :
- webhooks.py 3/3 : réception sans secret → {"status":"ok"} ; HMAC faux → 401
  (secret piloté par monkeypatch, déterministe quel que soit le .env) ;
  register sans secret → 400 AVANT tout appel réseau ; status avec
  gitea.list_webhooks stubbé → {"registered": False} (zéro réseau réel)
- notes.py 2/2 : GET HTML + roundtrip POST→GET (upsert persisté en base,
  échappement HTML vérifié : &lt;b&gt; et non <b>)
- sidebar_config.py 2/2 : GET défauts ; PUT persisté puis RELU depuis
  users.sidebar_config ; 400 sans config ; remise en état en fin de test
- github_routes.py 2/2 : status {"linked": False} ; disconnect {"status": "ok"}

Reste (A32 phase 2) : quasi nuls — library 1/10, api 3/23, dashboard 17/63,
api_v2 50/115 → même recette, fixture client existante.

suite **1047/1047** · `ruff check app tests` OK · docs à jour
2026-10-01 11:54:43 -04:00
8 changed files with 237 additions and 242 deletions
+45
View File
@@ -1,5 +1,50 @@
# 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
- `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4
routers qui n'avaient AUCUN test :
· `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ;
HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret →
400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé
(zéro accès réseau réel)
· `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté,
échappement HTML vérifié `&lt;b&gt;`)
· `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis
`users.sidebar_config`, 400 sans `config`, remise en état en fin de test
· `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok
- Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23,
`dashboard.py` 17/63, `api_v2.py` 50/115
- Suite complète : **1047/1047**
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
### Changed
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.10.0
7.12.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.10.0 (audit — A21 phase 2b : api_v2 bouclé, 111/115 hors loop) | **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
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.10.0",
version="7.12.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
-153
View File
@@ -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,
+1 -84
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.10.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": [
+186
View File
@@ -0,0 +1,186 @@
"""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 "&lt;b&gt;&amp;co&lt;/b&gt;" 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)