Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
360c705fd4 | ||
|
|
0698645dbd | ||
|
|
b2e38aece7 | ||
|
|
df9a269d76 | ||
|
|
da7326ffde |
+107
@@ -1,5 +1,112 @@
|
|||||||
# Changelog - FlowDeck
|
# 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
|
||||||
|
|
||||||
|
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
|
||||||
|
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
|
||||||
|
relecture du roundtrip
|
||||||
|
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
|
||||||
|
suppression vérifiée
|
||||||
|
· `card` POST : 404 sans board, `{"status": "ok"}` avec
|
||||||
|
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
|
||||||
|
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
|
||||||
|
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
|
||||||
|
· checklist mutations : PATCH item (checked/content relus EN BASE),
|
||||||
|
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
|
||||||
|
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
|
||||||
|
stub de transport httpx. Reste global : `dashboard.py` 17/63,
|
||||||
|
`api_v2.py` 50/115
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
|
||||||
|
|
||||||
|
## 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é `<b>`)
|
||||||
|
· `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é)
|
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# 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.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`
|
> **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
|
## Avancement Global
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.10.0",
|
version="7.15.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -341,74 +341,6 @@ def library_private(
|
|||||||
return {"items": items}
|
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}")
|
@router.get("/children/{page_id:int}")
|
||||||
def library_children(page_id: int, request: Request):
|
def library_children(page_id: int, request: Request):
|
||||||
"""Return child pages for a given parent page (for tree expansion in Library)."""
|
"""Return child pages for a given parent page (for tree expansion in Library)."""
|
||||||
@@ -449,91 +381,6 @@ def library_repository(
|
|||||||
return {"items": items}
|
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")
|
@router.get("/workspace")
|
||||||
def library_workspace(
|
def library_workspace(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
+1
-84
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.10.0"
|
"version": "7.15.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/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}": {
|
"/api/library/children/{page_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"/api/library/workspace": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
"""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 "<b>&co</b>" 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
|
||||||
|
|
||||||
|
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 = list(base.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)
|
||||||
|
|
||||||
|
|
||||||
|
# ── api.py (3/23 → 11/23) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _seed_board(client) -> None:
|
||||||
|
"""Board via l'endpoint lui-même (pas de SQL brut)."""
|
||||||
|
r = client.post(
|
||||||
|
f"/api/board-config/{OWNER}/{REPO}",
|
||||||
|
params={"columns": '["Todo", "Doing"]', "wip_limits": "{}"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_board_config_default_then_roundtrip(client):
|
||||||
|
# pas de board → défauts
|
||||||
|
r = client.get(f"/api/board-config/{OWNER}/other-repo")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["columns"] == ["Backlog", "À faire", "En cours", "Révision", "Terminé"]
|
||||||
|
# création puis relecture
|
||||||
|
_seed_board(client)
|
||||||
|
back = client.get(f"/api/board-config/{OWNER}/{REPO}")
|
||||||
|
assert back.json()["columns"] == ["Todo", "Doing"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_col_mapping_requires_board_and_works(client):
|
||||||
|
# board inexistant → 404
|
||||||
|
r = client.post(
|
||||||
|
"/api/col-mapping",
|
||||||
|
params={"owner": OWNER, "repo": "absent", "column": "Doing", "gitea_label": "x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
# avec board → ok (upsert) puis suppression
|
||||||
|
_seed_board(client)
|
||||||
|
r2 = client.post(
|
||||||
|
"/api/col-mapping",
|
||||||
|
params={"owner": OWNER, "repo": REPO, "column": "Doing", "gitea_label": "in-progress"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json() == {"status": "ok", "column": "Doing", "label": "in-progress"}
|
||||||
|
r3 = client.delete(
|
||||||
|
"/api/col-mapping", params={"owner": OWNER, "repo": REPO, "column": "Doing"}
|
||||||
|
)
|
||||||
|
assert r3.status_code == 200 and r3.json() == {"status": "ok", "column": "Doing"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_card_metadata(client):
|
||||||
|
r = client.post(f"/api/card/{OWNER}/absent/1", params={"priority": "high"})
|
||||||
|
assert r.status_code == 404 # board requis
|
||||||
|
_seed_board(client)
|
||||||
|
r2 = client.post(
|
||||||
|
f"/api/card/{OWNER}/{REPO}/1", params={"priority": "high", "due_date": "2026-10-01"}
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200 and r2.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_collaborators_stubbed(client, monkeypatch):
|
||||||
|
from app.services import gitea_client
|
||||||
|
|
||||||
|
async def _none(*_a, **_k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(gitea_client.gitea, "get_collaborators", _none)
|
||||||
|
r = client.get(f"/api/collaborators/{OWNER}/{REPO}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"collaborators": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_checklist_item_toggle_and_deletes(client):
|
||||||
|
"""PATCH item / DELETE item / DELETE checklist : mutation vérifiée en base."""
|
||||||
|
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"]
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, 1, 'A32')",
|
||||||
|
(board_id,),
|
||||||
|
)
|
||||||
|
cl_id = cur.lastrowid
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO checklist_items (checklist_id, content) VALUES (?, 'fait ?')",
|
||||||
|
(cl_id,),
|
||||||
|
)
|
||||||
|
item_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
try:
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/checklist-items/{item_id}", params={"checked": True, "content": "fait !"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT checked, content FROM checklist_items WHERE id=?", (item_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["checked"] == 1 and row["content"] == "fait !"
|
||||||
|
|
||||||
|
assert client.delete(f"/api/checklist-items/{item_id}").json() == {"status": "ok"}
|
||||||
|
assert client.delete(f"/api/checklists/{cl_id}").json() == {"status": "ok"}
|
||||||
|
with get_conn() as conn:
|
||||||
|
left = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM checklists WHERE id=?", (cl_id,)
|
||||||
|
).fetchone()["n"]
|
||||||
|
assert left == 0
|
||||||
|
finally:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (cl_id,))
|
||||||
|
conn.execute("DELETE FROM checklists WHERE id=?", (cl_id,))
|
||||||
|
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")
|
||||||
|
ok = client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
|
||||||
|
assert ok.status_code == 200 and ok.json() == {"status": "ok"}
|
||||||
|
invalid = client.post(
|
||||||
|
"/api/frontend-error", content="pas du json",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert invalid.status_code == 200 and invalid.json()["status"] == "ignored"
|
||||||
|
# doublon consécutif dédupliqué (count=2), lecture puis purge
|
||||||
|
client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user