Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a38ddd0f6 | ||
|
|
113374e499 |
@@ -1,5 +1,49 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.20.0 (2026-10-01) — Audit : A32 TERMINÉ (routes Gitea + bug fd_icon)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bug prod trouvé par les smokes** : `card_detail.html` appelait la macro
|
||||
`fd_icon` **sans l'importer** → `UndefinedError` → **500 systématique** sur
|
||||
`GET /api/issues/{o}/{r}/{id}?format=html` (seul rendu du template dans le
|
||||
code) → `{% from '_icons.html' import fd_icon %}` ajouté
|
||||
|
||||
### Tests
|
||||
|
||||
- Les **6 dernières routes d'A32** (Gitea, stub de transport, zéro réseau) :
|
||||
· stubs manuels sur `gitea_client.gitea` avec **état mutable partagé**
|
||||
(le handler PATCH re-fetch l'issue via `get_issue` — un canevas figé
|
||||
aurait masqué la mise à jour)
|
||||
· `POST /issues` : carte **insérée sur le board** ; `PATCH` : colonne
|
||||
recalculée **sans perdre la carte**
|
||||
· `GET /issues` JSON + **HTML** (`?format=html` — le segment `/html` ne fixe
|
||||
pas le paramètre `format`, il est lu dans la query) ; stub qui lève → 404
|
||||
· `POST /checklists` + `POST /checklist-items` : lignes **vérifiées en
|
||||
base**, 404 sans board
|
||||
- `test_smoke_uncovered.py` : **52 tests** ; suite complète **1089/1089**
|
||||
- **A32 complet** : plus aucun router « 0 test » (webhooks, notes,
|
||||
sidebar_config, github_routes, library, api, dashboard, api_v2)
|
||||
|
||||
## v7.19.0 (2026-10-01) — Audit : A32 : dashboard bloqué (44/44)
|
||||
|
||||
### Tests
|
||||
|
||||
- +5 routes `dashboard.py` — `test_smoke_uncovered.py` : 49 tests :
|
||||
· **Members** (POST/PUT/DELETE) : invitation de soi-même dans un workspace
|
||||
dédié, rôle relu en base, membre supprimé (`COUNT=0`) ; **quirk documenté**
|
||||
: les retours `(..., 400)` de ces routes sont sérialisés FastAPI en
|
||||
tableau + 200 (`[{"error": "Invalid role"}, 400]`)
|
||||
· `upload-folder` : **validations seules** (structure absente → 400,
|
||||
JSON cassé → 400) — zéro fichier écrit sur disque, workspace dédié nettoyé
|
||||
· `convert-to-database` : collection + propriété `title` + vue `table` +
|
||||
page en `content_format='collection'` **vérifiés en base**, 404 page
|
||||
inconnue, cleanup **dans l'ordre FK** (page avant collection)
|
||||
- **Recoupement final** : scan des 44 routes strictement à 0 ref de
|
||||
`dashboard.py` → **toutes exercées** (19 faux positifs résiduels =
|
||||
paths en f-string dans les tests, rapprochés manuellement)
|
||||
- Suite complète : **1086/1086**
|
||||
|
||||
## v7.18.0 (2026-10-01) — Audit : A32 phase 2g (dashboard +13)
|
||||
|
||||
### Tests
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.18.0 (audit — A32 phase 2g : dashboard +13, cycle items/tags) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.20.0 (audit — A32 TERMINÉ : routes Gitea stubbées + bug fd_icon) | **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
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.18.0",
|
||||
version="7.20.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{# Card detail modal content — full issue info + comments #}
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<div class="card-detail" x-data="cardDetail()">
|
||||
<!-- Title -->
|
||||
<div style="display:flex; align-items:flex-start; gap:12px; margin-bottom:16px;">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.18.0"
|
||||
"version": "7.20.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -596,6 +596,292 @@ def test_dashboard_settings_account_update(client):
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _own_workspace(client) -> int:
|
||||
"""Workspace appartenant à l'utilisateur de la SESSION (fallback actif)."""
|
||||
from app.db import get_conn
|
||||
|
||||
uid = client.get("/api/users/me").json()["id"]
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES ('Ws propre A32', ?)", (uid,)
|
||||
)
|
||||
ws_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return ws_id
|
||||
|
||||
|
||||
def test_dashboard_members_invite_role_remove(client):
|
||||
"""POST invite → PUT role → DELETE : cycle complet vérifié en base."""
|
||||
from app.db import get_conn
|
||||
|
||||
ws_id = _own_workspace(client)
|
||||
me = client.get("/api/users/me").json()
|
||||
try:
|
||||
r = client.post(
|
||||
f"/api/workspace/{ws_id}/members",
|
||||
json={"email": me["login"], "role": "editor"},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["status"] == "ok"
|
||||
assert r.json()["role"] == "editor"
|
||||
|
||||
# rôle invalide → quirk assumé (retour tuple FastAPI : tableau + 200)
|
||||
inval = client.post(
|
||||
f"/api/workspace/{ws_id}/members", json={"email": me["login"], "role": "hacker"}
|
||||
)
|
||||
assert inval.status_code == 200
|
||||
assert inval.json()[0]["error"] == "Invalid role"
|
||||
|
||||
up = client.put(
|
||||
f"/api/workspace/{ws_id}/members/{me['id']}", json={"role": "admin"}
|
||||
)
|
||||
assert up.status_code == 200 and up.json() == {"status": "ok"}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ws_id, me["id"]),
|
||||
).fetchone()
|
||||
assert row["role"] == "admin"
|
||||
|
||||
rm = client.delete(f"/api/workspace/{ws_id}/members/{me['id']}")
|
||||
assert rm.json() == {"status": "ok"}
|
||||
with get_conn() as conn:
|
||||
left = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ws_id, me["id"]),
|
||||
).fetchone()["n"]
|
||||
assert left == 0
|
||||
finally:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_dashboard_upload_folder_validation(client):
|
||||
"""A22 : validations du dépôt de dossier SANS écrire de fichier."""
|
||||
ws_id = _own_workspace(client)
|
||||
try:
|
||||
# structure absente → 400
|
||||
r = client.post(
|
||||
"/api/local-workspace/upload-folder", data={"parent_id": ""}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"] == "No structure provided"
|
||||
# structure JSON cassée → 400
|
||||
bad = client.post(
|
||||
"/api/local-workspace/upload-folder", data={"structure": "{pas du json"}
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
assert bad.json()["error"] == "Invalid structure JSON"
|
||||
finally:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_dashboard_convert_to_database(client):
|
||||
"""Convertit une page en base : collection + propriété Name + vue Table."""
|
||||
from app.db import get_conn
|
||||
|
||||
pid = _seed_page("À convertir A32")
|
||||
collection_id = None
|
||||
try:
|
||||
r = client.post(f"/api/pages/{pid}/convert-to-database", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["status"] == "converted" and d["view_url"] == f"/pages/{pid}"
|
||||
collection_id = d["collection_id"]
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT content_format, collection_id FROM pages WHERE id=?", (pid,)
|
||||
).fetchone()
|
||||
assert page["content_format"] == "collection"
|
||||
assert page["collection_id"] == collection_id
|
||||
coll = conn.execute(
|
||||
"SELECT name FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
assert coll["name"] == d["name"]
|
||||
prop = conn.execute(
|
||||
"SELECT prop_type FROM collection_properties WHERE collection_id=? AND prop_type='title'",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
assert prop is not None
|
||||
view = conn.execute(
|
||||
"SELECT view_type FROM collection_views WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
assert view["view_type"] == "table"
|
||||
|
||||
# page inconnue → 404
|
||||
assert client.post("/api/pages/999999/convert-to-database", json={}).status_code == 404
|
||||
finally:
|
||||
with get_conn() as conn:
|
||||
# ordre FK : la page référence la collection (pages.collection_id)
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
|
||||
if collection_id is not None:
|
||||
conn.execute("DELETE FROM collection_views WHERE collection_id=?", (collection_id,))
|
||||
conn.execute("DELETE FROM collection_properties WHERE collection_id=?", (collection_id,))
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── api.py : les 6 routes Gitea (stub de transport — zéro réseau) ────────────
|
||||
|
||||
_CANNED_ISSUE = {
|
||||
"number": 42, "title": "Issue A32", "body": "description a32",
|
||||
"state": "open", "labels": [], "assignee": None, "due_date": "",
|
||||
"created_at": "2026-10-01T00:00:00Z", "user": {"login": "giteux"},
|
||||
}
|
||||
|
||||
|
||||
def _stub_gitea(monkeypatch):
|
||||
from app.services import gitea_client
|
||||
|
||||
# état mutable partagé : le PATCH est re-fetché par le handler via
|
||||
# get_issue, donc update doit modifier ce que get renverra.
|
||||
state = dict(_CANNED_ISSUE)
|
||||
|
||||
async def _create(owner, repo, title, body, **_k):
|
||||
state.update({"title": title, "body": body})
|
||||
return dict(state)
|
||||
|
||||
async def _update(owner, repo, issue_id, **kwargs):
|
||||
state.update(kwargs)
|
||||
return dict(state)
|
||||
|
||||
async def _labels(owner, repo, issue_id, label_ids):
|
||||
return [{"id": i} for i in label_ids]
|
||||
|
||||
async def _get(owner, repo, issue_id):
|
||||
return dict(state)
|
||||
|
||||
async def _comments(owner, repo, issue_id):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(gitea_client.gitea, "create_issue", _create)
|
||||
monkeypatch.setattr(gitea_client.gitea, "update_issue", _update)
|
||||
monkeypatch.setattr(gitea_client.gitea, "update_issue_labels", _labels)
|
||||
monkeypatch.setattr(gitea_client.gitea, "get_issue", _get)
|
||||
monkeypatch.setattr(gitea_client.gitea, "get_issue_comments", _comments)
|
||||
|
||||
|
||||
def test_api_gitea_issue_create_update_with_board(client, monkeypatch):
|
||||
"""POST create → carte sur le board ; PATCH update → colonne recalculée."""
|
||||
from app.db import get_conn
|
||||
|
||||
_stub_gitea(monkeypatch)
|
||||
_seed_board(client) # board (OWNER, REPO) via l'endpoint
|
||||
with get_conn() as conn:
|
||||
board_id = conn.execute(
|
||||
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(OWNER, REPO),
|
||||
).fetchone()["id"]
|
||||
conn.execute("DELETE FROM cards WHERE board_id=?", (board_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
created = client.post(
|
||||
f"/api/issues/{OWNER}/{REPO}", params={"title": "Issue A32"}
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
assert created.json()["status"] == "ok"
|
||||
assert created.json()["issue"]["number"] == 42
|
||||
with get_conn() as conn:
|
||||
card = conn.execute(
|
||||
"SELECT column_name FROM cards WHERE board_id=? AND gitea_issue_id=42",
|
||||
(board_id,),
|
||||
).fetchone()
|
||||
assert card is not None # carte créée localement à partir de l'issue
|
||||
|
||||
patched = client.patch(
|
||||
f"/api/issues/{OWNER}/{REPO}/42", params={"title": "Renommé A32"}
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["issue"]["title"] == "Renommé A32"
|
||||
with get_conn() as conn:
|
||||
card2 = conn.execute(
|
||||
"SELECT column_name FROM cards WHERE board_id=? AND gitea_issue_id=42",
|
||||
(board_id,),
|
||||
).fetchone()
|
||||
assert card2 is not None # colonne recalculée (UPDATE) sans perdre la carte
|
||||
finally:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM cards WHERE board_id=?", (board_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_api_gitea_issue_detail_json_and_html(client, monkeypatch):
|
||||
"""GET détail (2 decorators, 1 handler) : JSON + rendu HTML."""
|
||||
_stub_gitea(monkeypatch)
|
||||
r = client.get(f"/api/issues/{OWNER}/{REPO}/42")
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d["issue"]["title"] == "Issue A32"
|
||||
assert d["comments"] == [] and d["checklists"] == [] and d["card"] is None
|
||||
|
||||
# le handler lit le paramètre `format` (le segment /html ne le fixe pas)
|
||||
html = client.get(f"/api/issues/{OWNER}/{REPO}/42/html", params={"format": "html"})
|
||||
assert html.status_code == 200
|
||||
assert "text/html" in html.headers["content-type"]
|
||||
assert "Issue A32" in html.text # card_detail.html rendu avec le canevas
|
||||
|
||||
# inconnu (le stub lève) → 404 propre
|
||||
from app.services import gitea_client
|
||||
|
||||
async def _boom(*_a, **_k):
|
||||
raise RuntimeError("pas trouvé")
|
||||
|
||||
monkeypatch.setattr(gitea_client.gitea, "get_issue", _boom)
|
||||
assert client.get(f"/api/issues/{OWNER}/{REPO}/999").status_code == 404
|
||||
|
||||
|
||||
def test_api_gitea_checklists_create_with_board(client):
|
||||
"""POST checklist + POST item : création vérifiée en base (DB seul)."""
|
||||
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"]
|
||||
cl_id = item_id = None
|
||||
try:
|
||||
r = client.post(
|
||||
f"/api/checklists/{OWNER}/{REPO}/42", params={"title": "À faire A32"}
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["status"] == "ok"
|
||||
cl_id = r.json()["checklist_id"]
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT board_id, title FROM checklists WHERE id=?", (cl_id,)
|
||||
).fetchone()
|
||||
assert row["board_id"] == board_id and row["title"] == "À faire A32"
|
||||
|
||||
r2 = client.post(
|
||||
f"/api/checklist-items/{OWNER}/{REPO}/42/{cl_id}",
|
||||
params={"content": "étape 1"},
|
||||
)
|
||||
assert r2.status_code == 200 and r2.json()["status"] == "ok"
|
||||
item_id = r2.json()["item_id"]
|
||||
with get_conn() as conn:
|
||||
it = conn.execute(
|
||||
"SELECT content FROM checklist_items WHERE id=?", (item_id,)
|
||||
).fetchone()
|
||||
assert it["content"] == "étape 1"
|
||||
# board absent → 404 (validation côté route)
|
||||
assert client.post("/api/checklists/absent/repo/1").status_code == 404
|
||||
finally:
|
||||
with get_conn() as conn:
|
||||
if item_id is not None:
|
||||
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
|
||||
if cl_id is not None:
|
||||
conn.execute("DELETE FROM checklists WHERE id=?", (cl_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_dashboard_gitea_workspace_page(client):
|
||||
r = client.get("/gitea-workspace")
|
||||
assert r.status_code in (200, 302), r.status_code
|
||||
|
||||
Reference in New Issue
Block a user