Compare commits

...
3 Commits
Author SHA1 Message Date
bruno 7a38ddd0f6 test: A32 TERMINÉ — 6 routes Gitea stubbées + bug prod fd_icon (v7.20.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Les 6 dernières routes d'A32 (api.py, gitea) avec stub de transport — zéro
réseau réel :

- _stub_gitea() : stubs manuels sur gitea_client.gitea (create_issue,
  update_issue, update_issue_labels, get_issue, get_issue_comments) 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 (board seedé par endpoint) ;
  PATCH : colonne recalculée sans perdre la carte.
- GET /issues JSON + HTML : ?format=html requis (le segment /html ne fixe pas
  le paramètre, le handler le lit dans la query) ; stub qui lève → 404.
- POST /checklists + POST /checklist-items : lignes vérifiées en base,
  404 sans board ; cleanup (items → checklists).

BUG PROD corrigé (trouvé par le smoke HTML) : card_detail.html utilisait la
macro fd_icon SANS l'importer → UndefinedError → 500 systématique sur
GET /api/issues/...?format=html (seul rendu du template dans le code).
Fix : {% from '_icons.html' import fd_icon %}.

A32 COMPLET : plus aucun router « 0 test » (webhooks, notes, sidebar_config,
github_routes, library, api, dashboard, api_v2 tous couverts).

test_smoke_uncovered.py : 52 tests. suite **1089/1089** · ruff OK · docs à jour
2026-10-01 15:39:21 -04:00
bruno 113374e499 test: A32 phase 2h — dashboard bloqué : 44/44 routes à 0 ref (v7.19.0)
FlowDeck CI / test (push) Failing after 3h12m50s
FlowDeck CI / lint (push) Successful in 1m54s
FlowDeck CI / docker (push) Skipped
+5 routes dashboard (fichier test_smoke_uncovered.py à 49 tests) :

- Members POST/PUT/DELETE : invitation de soi-même dans un workspace dédié
  (_own_workspace), rôle admin relu en base, membre supprimé (COUNT=0).
  Quirk documenté : les retours tuple des routes (`{"error": ...}, 400`)
  sont sérialisés FastAPI en tableau + 200 → assert sur `[0]["error"]`.
- upload-folder : validations SEULES (structure absente → 400 « No
  structure provided », JSON cassé → 400 « Invalid structure JSON ») —
  zéro fichier écrit, 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 (pages avant collections — IntegrityError corrigée).

Recoupement final : scan des 44 routes strictement à 0 ref de dashboard.py →
TOUTES exercées. Les 19 résidus du scan sont des faux positifs (paths en
f-string dans les tests : /api/workspace/1/…, f"/api/pages/{id}/…", …)
rapprochés manuellement un par un.

Reste A32 : les 6 routes Gitea d'api.py (issues ×4, créations checklists)
→ stub de transport httpx (effort S).

suite **1086/1086** · `ruff check app tests` OK · docs à jour
2026-10-01 15:24:38 -04:00
bruno 0cb476e336 test: A32 phase 2g — dashboard +13 routes, cycles items/tags (v7.18.0)
FlowDeck CI / lint (push) Successful in 1m58s
FlowDeck CI / test (push) Successful in 14m55s
FlowDeck CI / docker (push) Canceled after 0s
Cumul dashboard : 43 → 56 des 63 routes. 4 nouveaux tests (fichier à 46) :

- GET /gitea-workspace : page HTML (200 ou redirection propre)
- workspace/projects GET+POST : shape {builtin, gitea, github} avec
  github == [] ; projet créé RETROUVÉ dans builtin ; quirk « error » sans nom
  ; nettoyage (DELETE page)
- Cycle items local-workspace (5 routes) : POST création (titre relu),
  PUT rename (relu en base), PUT move, DELETE soft-delete (deleted_at relu),
  POST restore (deleted_at NULL relu) — nettoyage finally
- Cycle tags d'item (5 routes) : POST (urgenta32 lowercasé), tags de l'item,
  liste workspace, search (shape), suppression vérifiée. Utilisateur DÉDIÉ +
  workspace créé dans le test (le endpoint /api/local-workspace/tags exige un
  workspace actif : fallback « premier workspace du user » — on n'attache pas
  ce workspace à l'utilisateur fixture partagé), tout est nettoyé.

Reste A32 : dashboard 7 routes (members invite/role/unsubscribe,
upload-folder, convert-to-database) + 6 routes Gitea d'api.py (stub httpx).

suite **1083/1083** · `ruff check app tests` OK · docs à jour
2026-10-01 15:11:45 -04:00
8 changed files with 485 additions and 6 deletions
+64
View File
@@ -1,5 +1,69 @@
# 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
- +13 routes `dashboard.py` (cumul **43→56 sur 63**) —
`test_smoke_uncovered.py` : 46 tests :
· `/gitea-workspace` : page HTML (200 ou redirection propre)
· `workspace/projects` GET+POST : shape `{builtin, gitea, github}` avec
`github == []`, projet créé **retrouvé dans builtin**, quirk `error` sans
nom, nettoyage
· **Cycle items local-workspace** (5 routes) : création → renommage **relu en
base** → move → soft-delete (`deleted_at` **relu**) → restore
(`deleted_at IS NULL` **relu**), nettoyage
· **Cycle tags d'item** (5 routes) : POST (nom lowercasé), tags de l'item,
liste workspace, search (shape), suppression vérifiée. Utilisateur +
workspace **créés dans le test** : `/api/local-workspace/tags` a besoin
d'un workspace actif (fallback « premier workspace du user ») — on ne le
fait pas dépendre de l'utilisateur fixture partagé, tout est nettoyé
- Suite complète : **1083/1083**
## v7.17.0 (2026-10-01) — Audit : A32 phase 2f (dashboard +7, garde-fous A16)
### Tests
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.17.0
7.20.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.17.0 (audit — A32 phase 2f : dashboard +7, garde-fous A16) | **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
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.17.0",
version="7.20.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+1
View File
@@ -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;">
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.17.0"
"version": "7.20.0"
},
"paths": {
"/auth/register": {
+414
View File
@@ -596,6 +596,420 @@ 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
if r.status_code == 200:
assert "text/html" in r.headers["content-type"]
def test_dashboard_workspace_projects_crud(client):
from app.db import get_conn
listing = client.get("/api/workspace/projects")
assert listing.status_code == 200
d = listing.json()
assert isinstance(d["builtin"], list) and isinstance(d["gitea"], list)
assert d["github"] == []
created = client.post("/api/workspace/projects", json={"name": "Projet A32"})
assert created.status_code == 200
body = created.json()
assert body["name"] == "Projet A32" and body["forge"] == "builtin"
try:
# la page créée est un projet racine → elle apparaît dans builtin
names = [p["name"] for p in client.get("/api/workspace/projects").json()["builtin"]]
assert "Projet A32" in names
vide = client.post("/api/workspace/projects", json={"name": " "})
assert "error" in vide.json() # quirk : 200 + message
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (body["id"],))
conn.commit()
def test_dashboard_local_workspace_item_lifecycle(client):
"""POST → rename → move → soft-delete → restore (5 routes, vérifié en base)."""
from app.db import get_conn
created = client.post("/api/local-workspace/items", json={"name": "Fichier A32"})
assert created.status_code == 200
item = created.json()
iid = item["id"]
try:
assert item["name"] == "Fichier A32" and item["type"] == "page"
with get_conn() as conn:
row = conn.execute("SELECT title FROM pages WHERE id=?", (iid,)).fetchone()
assert row["title"] == "Fichier A32"
assert client.put(
f"/api/local-workspace/items/{iid}", json={"name": "Renommé A32"}
).json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute("SELECT title FROM pages WHERE id=?", (iid,)).fetchone()
assert row["title"] == "Renommé A32"
assert client.put(
f"/api/local-workspace/items/{iid}/move", json={"parent_id": None}
).json() == {"status": "ok"}
assert client.delete(f"/api/local-workspace/items/{iid}").json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (iid,)).fetchone()
assert row["deleted_at"] is not None
assert client.post(f"/api/local-workspace/items/{iid}/restore").json() == {
"status": "ok"
}
with get_conn() as conn:
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (iid,)).fetchone()
assert row["deleted_at"] is None
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (iid,))
conn.commit()
def test_dashboard_item_tags_lifecycle(client):
"""POST tag item → lecture → liste workspace → search → suppression.
L'utilisateur et son workspace sont créés DANS le test : /tags a besoin
d'un workspace actif (fallback « premier workspace du user ») et on ne
veut pas que ce workspace appartienne à l'utilisateur fixture partagé.
"""
from app.db import get_conn
reg = client.post(
"/auth/register",
json={"email": "[email protected]", "password": "secret123", "name": "taguser"},
)
assert reg.status_code == 200, reg.text
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Ws A32', ?)", (uid,)
)
ws_id = cur.lastrowid
conn.commit()
iid = _seed_page("Item taggé A32")
tag_id = None
try:
created = client.post(
f"/api/local-workspace/items/{iid}/tags", json={"name": "UrgentA32"}
)
assert created.status_code == 200
tag_id = created.json()["tag"]["id"]
assert created.json()["tag"]["name"] == "urgenta32" # lowercasé
item_tags = client.get(f"/api/local-workspace/items/{iid}/tags").json()["tags"]
assert any(t["id"] == tag_id for t in item_tags)
all_tags = client.get("/api/local-workspace/tags")
assert all_tags.status_code == 200
assert any(t["id"] == tag_id for t in all_tags.json()["tags"])
search = client.get("/api/local-workspace/tags/search", params={"tags": "urgentA32"})
assert search.status_code == 200 and isinstance(search.json()["items"], list)
assert client.delete(
f"/api/local-workspace/items/{iid}/tags/{tag_id}"
).json() == {"status": "ok"}
after = client.get(f"/api/local-workspace/items/{iid}/tags").json()["tags"]
assert all(t["id"] != tag_id for t in after)
finally:
with get_conn() as conn:
if tag_id is not None:
conn.execute("DELETE FROM tags WHERE id=?", (tag_id,))
conn.execute("DELETE FROM pages WHERE id=?", (iid,))
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
conn.commit()
def test_dashboard_files_traversal_denied_and_serve(client):
"""A16 : /api/files refuse le traversal et sert les vrais fichiers."""
import pathlib as _pathlib