"""FlowDeck — My Tasks : contrat « façon Notion » + cohérence workspace. Bug d'origine : après suppression de tous les documents d'un workspace, la vue My Tasks continuait d'afficher les tâches de ce workspace (les bases et leurs lignes survivaient, la vue ne vérifiait rien). Évolution : My Tasks devient un poste de travail **transverse** (tous les workspaces, comme l'onglet *Home* de Notion) alimenté par des **sources opt-in** : une base n'émet ses tâches qu'après « conversion en base de tâches » et le mapping explicite de ses 3 colonnes requises. Ce fichier verrouille : * le périmètre transverse (plus de scoping par `flowdeck_workspace`) ; * l'opt-in : une base non convertie n'émet rien ; une conversion incomplète n'émet rien non plus ; * le mapping **nommé** (Assigné à / Statut / Échéance) et sa validation ; * la limite de 10 bases connectées ; * les 3 vues, les filtres globaux et le tri ; * l'édition en direct (statut / échéance / titre) via le mapping ; * l'ajout rapide multi-destinations (auto-assigné) ; * les cascades de suppression (corbeille / purge / workspace / base). """ from __future__ import annotations import contextlib import json import os import re import tempfile from datetime import UTC, date, timedelta import pytest from conftest import login_test_client @pytest.fixture def client(): db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) db_path = db_file.name db_file.close() os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["LLM_PROVIDER"] = "offline" from app.config import settings from app.db import init_db from app.main import app settings.database_url = f"sqlite:///{db_path}" settings.llm_provider = "offline" settings.rate_limit_enabled = False init_db() from fastapi.testclient import TestClient client = login_test_client(TestClient(app)) try: yield client finally: for suffix in ("", "-wal", "-shm"): try: os.unlink(db_path + suffix) except FileNotFoundError: pass @contextlib.contextmanager def _conn(): from app.db import get_conn with get_conn() as conn: yield conn def _one(sql: str, params=()): """Première ligne, via une connexion qui se ferme toute seule.""" with _conn() as conn: row = conn.execute(sql, params).fetchone() return dict(row) if row else None def _make_workspace(client, name: str) -> int: r = client.post("/api/workspaces", json={"name": name}) assert r.status_code == 200, r.text return r.json()["id"] def _host_page(ws_id: int, title: str) -> int: with _conn() as conn: page_id = conn.execute( "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " "parent_section) VALUES ('', ?, ?, '', 'blocks', 'Private')", (ws_id, title), ).lastrowid conn.commit() return page_id def _prop(cid: int, name: str, prop_type: str, options=None) -> int: with _conn() as conn: pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties " "WHERE collection_id=?", (cid,), ).fetchone()[0] pid = conn.execute( "INSERT INTO collection_properties (collection_id, name, prop_type, " "options_json, position, required, visible_in_views) VALUES (?,?,?,?,?,0,1)", (cid, name, prop_type, json.dumps(options or []), pos), ).lastrowid conn.commit() return pid def _database(client, ws_id: int, name: str, *, host_page: bool = True, rows: int = 0) -> int: """Base pleine page si `host_page`, sinon base rattachée au workspace seule.""" page = _host_page(ws_id, name) if host_page else None with _conn() as conn: cid = conn.execute( "INSERT INTO collections (name, workspace_id, parent_page_id) VALUES (?, ?, ?)", (name, ws_id, page), ).lastrowid if page: conn.execute( "UPDATE pages SET content_format='collection', collection_id=? WHERE id=?", (cid, page), ) conn.commit() if rows: with _conn() as conn: for i in range(rows): conn.execute( "INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)", (cid, f"{name} #{i + 1}"), ) conn.commit() return cid def _host_page_id(cid: int) -> int: with _conn() as conn: return conn.execute( "SELECT parent_page_id FROM collections WHERE id=?", (cid,) ).fetchone()[0] def _other_user() -> dict: """Un second utilisateur (la base de test n'en contient qu'un).""" with _conn() as conn: row = conn.execute( "SELECT * FROM users WHERE id<>? ORDER BY id LIMIT 1", (_user()["id"],) ).fetchone() if row is not None: return dict(row) uid = conn.execute( "INSERT INTO users (login, full_name, is_admin) VALUES ('other','Other',0)" ).lastrowid conn.commit() return _one("SELECT * FROM users WHERE id=?", (uid,)) def _today() -> date: """« Aujourd'hui » selon le service (fuseau de l'utilisateur, sinon UTC).""" from app.services.task_databases import user_today as service_today with _conn() as conn: return service_today(conn, _user()["id"]) def _user() -> dict: return _one("SELECT * FROM users ORDER BY id LIMIT 1") def _connect(client, cid: int, *, assignee: str = "Assigné à", status: str = "Statut", due: str = "Échéance") -> dict: """Convertit la base via l'API (mapping explicite, colonnes créées).""" r = client.post(f"/db/{cid}/task-db/api", json={ "create_missing": { "assignee": {"name": assignee, "prop_type": "person"}, "status": {"name": status, "prop_type": "status", "options": [{"name": "À faire"}, {"name": "En cours"}, {"name": "Terminée"}]}, "due": {"name": due, "prop_type": "date"}, } }) assert r.status_code == 200, r.text return r.json() def _row(cid: int, title: str, *, user=None, status: str = "À faire", due: str = "") -> int: """Ligne dont les valeurs sont **indexées par ID de colonne**.""" with _conn() as conn: props = {p["id"]: p for p in conn.execute( "SELECT id, prop_type FROM collection_properties WHERE collection_id=?", (cid,) ).fetchall()} a = props[[i for i, p in props.items() if p["prop_type"] == "person"][0]]["id"] s = props[[i for i, p in props.items() if p["prop_type"] == "status"][0]]["id"] d = props[[i for i, p in props.items() if p["prop_type"] == "date"][0]]["id"] pv = {str(a): [{"id": user["id"], "login": user["login"], "full_name": user["full_name"] or user["login"]}], str(s): status} if due: pv[str(d)] = due rid = conn.execute( "INSERT INTO collection_pages (collection_id, title, position, " "property_values_json) VALUES (?, ?, 0, ?)", (cid, title, json.dumps(pv)), ).lastrowid conn.commit() return rid def _api(client, **params): r = client.get("/my-tasks/api", params=params) assert r.status_code == 200, r.text return r.json() # ── Périmètre transverse + opt-in ──────────────────────────────────────── def test_my_tasks_spans_all_workspaces(client): """Toutes les bases connectées, quel que soit le workspace actif.""" a = _make_workspace(client, "Alpha") b = _make_workspace(client, "Beta") for ws, name, n in ((a, "Tâches A", 2), (b, "Tâches B", 3)): cid = _database(client, ws, name) _connect(client, cid) u = _user() for i in range(n): _row(cid, f"{name} #{i + 1}", user=u) body = _api(client) assert body["total"] == 5, body assert {s["name"] for s in body["sources"]} == {"Tâches A", "Tâches B"} # Le cookie de workspace ne cadre plus la vue. assert _api(client, **{})["total"] == 5 def test_unconverted_database_feeds_nothing(client): """Opt-in : une base jamais convertie n'apparaît pas.""" ws = _make_workspace(client, "OptIn") cid = _database(client, ws, "Backlog", rows=2) body = _api(client) assert body["total"] == 0 assert body["sources_total"] == 0 assert body["tasks"] == [] _connect(client, cid) # La conversion crée les colonnes : les 2 lignes existantes n'ont pas de # statut, mais elles sont listées (assigné vide = implicite « mienne »). assert _api(client)["sources_total"] == 1 def test_mapping_incomplete_emits_nothing_but_is_listed(client): """Base connectée au mapping incomplet : visible, mais silencieuse.""" ws = _make_workspace(client, "Partial") cid = _database(client, ws, "Partielle") status_prop = _prop(cid, "Statut", "status", [{"name": "À faire"}, {"name": "Terminée"}]) _prop(cid, "Échéance", "date") # Connectée mais mapping partiel : seul le statut est lié. with _conn() as conn: conn.execute( "UPDATE collections SET is_task=1, task_status_prop=? WHERE id=?", (status_prop, cid), ) conn.commit() body = _api(client) assert body["sources_total"] == 1 assert body["sources"][0]["configured"] is False assert set(body["sources"][0]["missing_roles"]) == {"assignee", "due"} assert body["total"] == 0 def test_only_tasks_assigned_to_me_are_listed(client): ws = _make_workspace(client, "Assignees") cid = _database(client, ws, "Sprint") _connect(client, cid) u = _user() other = _other_user() _row(cid, "Pour moi", user=u) _row(cid, "Pour quelqu'un d'autre", user=other) body = _api(client) assert body["total"] == 1 assert body["tasks"][0]["title"] == "Pour moi" assert body["tasks"][0]["assignees"] == [u["login"]] # ── Mapping nommé + validation ─────────────────────────────────────────── def test_conversion_links_existing_columns_by_name(client): """Des colonnes déjà présentes sont **liées**, pas dupliquées.""" ws = _make_workspace(client, "Linking") cid = _database(client, ws, "Projets") a = _prop(cid, "Resp.", "person") s = _prop(cid, "Avancement", "select", [{"name": "Fait"}]) d = _prop(cid, "Livraison", "date") state = client.get(f"/db/{cid}/task-db/api").json() assert {p["id"] for p in state["candidates"]["assignee"]} == {a} assert {p["id"] for p in state["candidates"]["status"]} == {s} assert {p["id"] for p in state["candidates"]["due"]} == {d} out = _connect(client, cid, assignee="Resp.", status="Avancement", due="Livraison") assert out["mapping"] == {"assignee": a, "status": s, "due": d} with _conn() as conn: names = {r["name"] for r in conn.execute( "SELECT name FROM collection_properties WHERE collection_id=?", (cid,) )} assert names == {"Resp.", "Avancement", "Livraison"} # aucune colonne créée def test_conversion_rejects_incompatible_column(client): """Une colonne Date ne peut pas servir de colonne « Assigné à ».""" ws = _make_workspace(client, "Types") cid = _database(client, ws, "Typée") d = _prop(cid, "Date", "date") r = client.post(f"/db/{cid}/task-db/api", json={"mapping": {"assignee": d}}) assert r.status_code == 400 assert "incompatible" in r.json()["detail"].lower() def test_conversion_rejects_a_column_used_twice(client): """Une même colonne ne peut pas couvrir deux rôles (Notion l'interdit).""" ws = _make_workspace(client, "Double") cid = _database(client, ws, "Double emploi") # Type `select` : compatible à la fois de « Statut »… mais pas de « Date ». sel = _prop(cid, "Etat", "select", [{"name": "A"}]) other = _prop(cid, "Autre Etat", "select", [{"name": "B"}]) # Deux rôles de même famille de types compatibles → double emploi détecté. r = client.post(f"/db/{cid}/task-db/api", json={"mapping": { "assignee": None, "status": sel, "due": other, }}) assert r.status_code == 400 # Et le cas typique : la même colonne pour deux rôles compatibles. r = client.post(f"/db/{cid}/task-db/api", json={ "mapping": {"status": sel}, "create_missing": {"assignee": {"prop_type": "person"}}, }) assert r.status_code == 200, r.text r = client.post(f"/db/{cid}/task-db/api", json={ "mapping": {"status": sel, "due": sel}, }) assert r.status_code == 400 assert "incompatible" in r.json()["detail"].lower() def test_disconnect_keeps_the_tasks(client): ws = _make_workspace(client, "Disconnect") cid = _database(client, ws, "Tasks") _connect(client, cid) u = _user() _row(cid, "Gardée", user=u) assert _api(client)["total"] == 1 r = client.delete(f"/db/{cid}/task-db/api") assert r.status_code == 200, r.text assert _api(client)["total"] == 0 with _conn() as conn: assert conn.execute( "SELECT COUNT(*) FROM collection_pages WHERE id IS NOT NULL AND collection_id=?", (cid,), ).fetchone()[0] == 1 def test_sources_are_capped_at_ten(client): ws = _make_workspace(client, "Dix") for i in range(10): _connect(client, _database(client, ws, f"Base {i}")) listed = client.get("/db/task-dbs/api") assert listed.status_code == 200, listed.text assert listed.json()["limit_reached"] is True r = client.post(f"/db/{_database(client, ws, 'Base 11')}/task-db/api", json={}) assert r.status_code == 409 assert "10" in r.json()["detail"] # ── Filtres, tri, vues ─────────────────────────────────────────────────── def _seed(client, ws: int) -> int: cid = _database(client, ws, "Backlog") _connect(client, cid) u = _user() today = _today() _row(cid, "En retard", user=u, status="À faire", due=(today - timedelta(days=3)).isoformat()) _row(cid, "Aujourd'hui", user=u, status="En cours", due=today.isoformat()) _row(cid, "Future", user=u, status="À faire", due=(today + timedelta(days=10)).isoformat()) _row(cid, "Terminée", user=u, status="Terminée", due=(today - timedelta(days=1)).isoformat()) _row(cid, "Sans date", user=u, status="À faire") return cid def test_due_filters_and_hide_done(client): ws = _make_workspace(client, "Filters") _seed(client, ws) def titles(**kw): return [t["title"] for t in _api(client, **kw)["tasks"]] assert len(titles(due="all")) == 5 assert titles(due="today") == ["Aujourd'hui"] assert set(titles(due="overdue")) == {"En retard", "Aujourd'hui", "Terminée"} assert set(titles(due="week")) == {"En retard", "Aujourd'hui", "Terminée"} # borné à J+7 assert "Terminée" not in titles(hide_done="true") # Tri par statut : les tâches terminées passent en fin de liste. by_status = titles(due="all", sort="status") assert by_status[-1] == "Terminée" assert "Terminée" not in titles(due="all", sort="status", hide_done="true") def test_unknown_view_and_sort_fall_back(client): """Un paramètre d'URL hors enum est ramené à une valeur réelle.""" ws = _make_workspace(client, "Guards") _seed(client, ws) body = _api(client, view="n'importe quoi", sort="; DROP TABLE pages;") assert body["filters"]["sort"] == "due" assert body["total"] == 5 def test_due_state_flags_drive_the_table_styling(client): ws = _make_workspace(client, "States") _seed(client, ws) states = {t["title"]: t["due_state"] for t in _api(client)["tasks"]} assert states["En retard"] == "overdue" assert states["Aujourd'hui"] == "today" assert states["Future"] == "upcoming" assert states["Sans date"] == "" def test_task_payload_carries_its_source_and_columns(client): """Chaque tâche porte sa source et les colonnes à écrire (pas de devinette).""" ws = _make_workspace(client, "Payload") cid = _seed(client, ws) task = _api(client)["tasks"][0] assert task["collection_id"] == cid assert task["collection_name"] == "Backlog" assert task["workspace_name"] == "Payload" assert task["status_prop"] and task["due_prop"] and task["assignee_prop"] assert task["status_options"][0] == "À faire" # ── Modification en direct ─────────────────────────────────────────────── def test_editing_status_writes_the_mapped_column(client): ws = _make_workspace(client, "Edit") _seed(client, ws) task = next(t for t in _api(client)["tasks"] if t["title"] == "Sans date") r = client.put(f"/my-tasks/api/task/{task['id']}", json={"field": "status", "value": "En cours"}) assert r.status_code == 200, r.text assert r.json()["task"]["status"] == "En cours" with _conn() as conn: pv = json.loads(conn.execute( "SELECT property_values_json FROM collection_pages WHERE id=?", (task["id"],), ).fetchone()[0]) assert pv[str(task["status_prop"])] == "En cours" def test_editing_due_and_title(client): ws = _make_workspace(client, "Edit2") _seed(client, ws) task = next(t for t in _api(client)["tasks"] if t["title"] == "Future") iso = (_today() + timedelta(days=2)).isoformat() client.put(f"/my-tasks/api/task/{task['id']}", json={"field": "due", "value": iso}) client.put(f"/my-tasks/api/task/{task['id']}", json={"field": "title", "value": "Future renommée"}) body = _api(client) updated = next(t for t in body["tasks"] if t["id"] == task["id"]) assert updated["due"] == iso assert updated["title"] == "Future renommée" def test_unassigning_removes_the_task_from_my_tasks(client): ws = _make_workspace(client, "Unassign") _seed(client, ws) task = next(t for t in _api(client)["tasks"] if t["title"] == "Sans date") other = _other_user() r = client.put(f"/my-tasks/api/task/{task['id']}", json={ "field": "assignee", "value": [{"id": other["id"], "login": other["login"]}], }) assert r.status_code == 200, r.text assert r.json()["left_mytasks"] is True assert task["id"] not in [t["id"] for t in _api(client)["tasks"]] def test_update_rejects_unknown_field_and_foreign_source(client): ws = _make_workspace(client, "Guards2") cid = _seed(client, ws) task = _api(client)["tasks"][0] r = client.put(f"/my-tasks/api/task/{task['id']}", json={"field": "collection_id", "value": 1}) assert r.status_code == 400 with _conn() as conn: conn.execute("UPDATE collections SET is_task=0 WHERE id=?", (cid,)) conn.commit() r = client.put(f"/my-tasks/api/task/{task['id']}", json={"field": "status", "value": "À faire"}) assert r.status_code == 400 assert "no longer feeds" in r.json()["detail"] # ── Ajout rapide multi-destinations ────────────────────────────────────── def test_quick_add_creates_a_self_assigned_task_in_the_chosen_base(client): ws = _make_workspace(client, "QuickAdd") a = _database(client, ws, "Clients") b = _database(client, ws, "Editorial") for cid in (a, b): _connect(client, cid) r = client.post("/my-tasks/api/task", json={"collection_id": b, "title": "Nouvelle vidéo", "due": "2030-01-02"}) assert r.status_code == 200, r.text assert r.json()["collection_id"] == b body = _api(client) assert body["total"] == 1 task = body["tasks"][0] assert task["title"] == "Nouvelle vidéo" assert task["collection_name"] == "Editorial" assert task["assignees"] == [_user()["login"]] # auto-assignée assert task["due"] == "2030-01-02" def test_quick_add_refuses_a_database_that_does_not_feed_my_tasks(client): ws = _make_workspace(client, "QuickAdd2") cid = _database(client, ws, "Non connectée") r = client.post("/my-tasks/api/task", json={"collection_id": cid, "title": "X"}) assert r.status_code == 400 assert "does not feed" in r.json()["detail"] def test_quick_add_validates_its_input(client): ws = _make_workspace(client, "QuickAdd3") cid = _database(client, ws, "Tasks") _connect(client, cid) assert client.post("/my-tasks/api/task", json={"title": "Sans base"}).status_code == 400 assert client.post("/my-tasks/api/task", json={"collection_id": cid}).status_code == 400 assert client.post("/my-tasks/api/task", json={"collection_id": cid, "title": " "}).status_code == 400 def test_quick_add_requires_a_session(client): cid = _database(client, _make_workspace(client, "Anon"), "Tasks") r = client.post("/my-tasks/api/task", json={"collection_id": cid, "title": "X"}, cookies={"flowdeck_session": ""}) assert r.status_code in (401, 403) # ── Rendu de la page ───────────────────────────────────────────────────── def test_dashboard_renders_the_three_views(client): ws = _make_workspace(client, "Render") _seed(client, ws) r = client.get("/my-tasks") assert r.status_code == 200, r.text assert 'id="my-tasks-app"' in r.text assert "/static/js/my_tasks.js" in r.text # Le cookie de workspace n'est plus réécrit par cette vue. assert "flowdeck_workspace" not in r.headers.get("set-cookie", "") for view in ("table", "board", "calendar"): r = client.get("/my-tasks", params={"view": view}) assert r.status_code == 200, r.text # Le JSON est échappé pour l'attribut HTML (`"`). assert "my-tasks-app" in r.text assert """ in r.text and view in r.text def test_dashboard_without_source_explains_the_conversion(client): ws = _make_workspace(client, "NoSource") _database(client, ws, "Backlog", rows=1) r = client.get("/my-tasks") assert r.status_code == 200, r.text assert "Convertir en base de tâches" in r.text assert "Jusqu'à 10 bases" in r.text # Rien à afficher côté client → le script n'est pas chargé, mais la # feuille de style l'est (sinon l'invite retombe à gauche). assert "/static/js/my_tasks.js" not in r.text assert "/static/css/my_tasks.css" in r.text def test_dashboard_without_any_workspace(client): r = client.get("/my-tasks") assert r.status_code in (200, 302), r.text # ── Cohérence workspace : corbeille, purge, suppression ────────────────── def test_trashed_database_host_page_hides_its_tasks(client): """Base à la corbeille → plus de tâches, la base reste restaurable.""" ws = _make_workspace(client, "Coherence A") cid = _database(client, ws, "Backlog") _connect(client, cid) u = _user() for i in range(3): _row(cid, f"Backlog #{i + 1}", user=u) assert _api(client)["total"] == 3 assert _api(client)["total"] == 3 page = _host_page_id(cid) client.post(f"/api/pages/{page}/trash") body = _api(client) assert body["total"] == 0 assert body["tasks"] == [] with _conn() as conn: assert conn.execute( "SELECT COUNT(*) FROM collections WHERE name='Backlog'").fetchone()[0] == 1 client.post(f"/board/api/trash/{page}/restore") assert _api(client)["total"] == 3 def test_trashed_row_content_page_hides_the_row(client): """La page contenu d'une ligne à la corbeille → la ligne sort de My Tasks.""" ws = _make_workspace(client, "Coherence B") cid = _database(client, ws, "Tasks") _connect(client, cid) rid = _row(cid, "Avec page", user=_user()) with _conn() as conn: page_id = conn.execute( "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " "parent_section, collection_id, collection_row_id) " "VALUES ('', ?, 'Row doc', '', 'blocks', 'DbRow', ?, ?)", (ws, cid, rid), ).lastrowid conn.commit() assert _api(client)["total"] == 1 client.post(f"/api/pages/{page_id}/trash") assert _api(client)["total"] == 0 def test_permanent_delete_of_host_page_deletes_the_database(client): ws = _make_workspace(client, "Coherence E") cid = _database(client, ws, "Backlog", rows=2) page = _host_page_id(cid) client.post(f"/api/pages/{page}/trash") r = client.delete(f"/board/api/trash/{page}") assert r.status_code == 200, r.text assert r.json()["collections"] == 1 with _conn() as conn: assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?", (cid,)).fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", (page,)).fetchone()[0] == 0 def test_trash_purge_deletes_hosted_databases(client): from app.services.trash import purge_expired ws = _make_workspace(client, "Coherence F") cid = _database(client, ws, "Backlog", rows=1) page = _host_page_id(cid) with _conn() as conn: conn.execute( "UPDATE pages SET deleted_at='2000-01-01 00:00:00' WHERE id=?", (page,)) conn.commit() summary = purge_expired(days=30) assert page in summary["purged"] with _conn() as conn: assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?", (cid,)).fetchone()[0] == 0 def test_empty_trash_deletes_hosted_databases(client): ws = _make_workspace(client, "Coherence G") cid = _database(client, ws, "Backlog", rows=1) page = _host_page_id(cid) client.post(f"/api/pages/{page}/trash") r = client.post("/board/api/trash/empty") assert r.status_code == 200, r.text assert r.json()["collections"] == 1 with _conn() as conn: assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?", (cid,)).fetchone()[0] == 0 def test_delete_workspace_removes_its_databases(client): ws = _make_workspace(client, "Coherence H") cid = _database(client, ws, "Backlog", rows=1) r = client.delete(f"/api/workspaces/{ws}") assert r.status_code == 200, r.text assert r.json()["collections"] == 1 with _conn() as conn: assert conn.execute("SELECT COUNT(*) FROM collections WHERE id=?", (cid,)).fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM pages WHERE workspace_id=?", (ws,)).fetchone()[0] == 0 def test_delete_collection_detaches_host_page(client): """Supprimer une base laisse la page hôte vivante (plus de FK bloquante).""" ws = _make_workspace(client, "Coherence I") cid = _database(client, ws, "Backlog", rows=1) page = _host_page_id(cid) r = client.delete(f"/db/api/{cid}") assert r.status_code == 200, r.text assert r.json()["status"] == "deleted" with _conn() as conn: row = conn.execute("SELECT * FROM pages WHERE id=?", (page,)).fetchone() assert row["collection_id"] is None assert row["content_format"] == "blocks" def test_today_follows_the_user_timezone(client): """« Aujourd'hui » suit ``users.timezone`` — le fuseau que le projet utilise déjà pour le calendrier des bases. Sans cela, une tâche due aujourd'hui bascule dans « demain » (ou l'inverse) dès que l'utilisateur est à plus de quelques heures d'UTC. """ from datetime import datetime from app.services.task_databases import user_today as service_today ws = _make_workspace(client, "TZ") cid = _database(client, ws, "Tasks") _connect(client, cid) uid = _user()["id"] def set_tz(tz): with _conn() as conn: conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, uid)) conn.commit() def service_day(): with _conn() as conn: return service_today(conn, uid) try: # Sans fuseau : UTC (le comportement historique du service). set_tz("") assert service_day() == datetime.now(UTC).date() # Kiritimati est à UTC+14 : le jour du service peut avancer d'un cran. set_tz("Pacific/Kiritimati") ahead = service_day() assert ahead in (datetime.now(UTC).date(), (datetime.now(UTC) + timedelta(hours=14)).date()) # Une tâche datée du jour du service est « aujourd'hui » pour la vue. _row(cid, "Du jour", user=_user(), due=ahead.isoformat()) assert [t["title"] for t in _api(client, due="today")["tasks"]] == ["Du jour"] # Si le jour du service diffère de celui d'UTC, une tâche datée du # jour UTC ne doit PAS être considérée comme « aujourd'hui ». # On repart d'un jeu de lignes vide : « Du jour » était daté de # `ahead` (Kiritimati) et le tri des tâches n'est pas garanti entre # les deux fuseaux. set_tz("Pacific/Midway") # UTC-7 behind = service_day() with _conn() as conn: conn.execute( "DELETE FROM collection_pages WHERE collection_id=? " "AND title IN ('Du jour', 'Jour UTC')", (cid,), ) conn.commit() # Le jour « aujourd'hui » suit le fuseau de l'utilisateur. _row(cid, "Jour du service", user=_user(), due=behind.isoformat()) got = [t["title"] for t in _api(client, due="today")["tasks"]] assert got == ["Jour du service"], got # Le jour UTC n'en fait partie que s'il coïncide avec celui du # service — ce qui dépend de l'heure : on ne l'affirme que dans ce cas. utc_day = datetime.now(UTC).date() if utc_day != behind: _row(cid, "Jour UTC", user=_user(), due=utc_day.isoformat()) got = [t["title"] for t in _api(client, due="today")["tasks"]] assert "Jour UTC" not in got finally: with _conn() as conn: conn.execute("UPDATE users SET timezone='' WHERE id=?", (uid,)) conn.commit() # ── Page « /db » : destination du bouton « Ouvrir mes bases » ─────────── def test_db_page_lists_the_workspace_databases(client): """`/db` était un dump JSON de debug sans layout : il sert de destination au bouton « Ouvrir mes bases » de My Tasks.""" ws = _make_workspace(client, "Index") _database(client, ws, "Clients") _database(client, ws, "Editorial") r = client.get("/db", cookies={"flowdeck_workspace": str(ws)}) assert r.status_code == 200, r.text assert "Bases de données" in r.text assert "Clients" in r.text and "Editorial" in r.text assert "
" not in r.text          # plus de dump JSON
    assert "base de tâches" in r.text.lower()


def test_db_page_shows_the_task_database_state(client):
    """Chaque base annonce son état : connectée, à configurer, ou à convertir."""
    ws = _make_workspace(client, "Index2")
    _database(client, ws, "Non configurée")
    linked = _database(client, ws, "Connectée")
    _connect(client, linked)

    r = client.get("/db", cookies={"flowdeck_workspace": str(ws)})
    assert r.status_code == 200, r.text
    assert "Base de tâches" in r.text
    assert "Convertir en base de tâches" in r.text
    assert "Alimente My Tasks" in r.text


def test_db_page_skips_databases_whose_host_page_is_trashed(client):
    """Une base dont la page hôte est à la corbeille n'est pas proposée."""
    ws = _make_workspace(client, "Index3")
    _database(client, ws, "Vivante")
    gone = _database(client, ws, "Supprimée")
    client.post(f"/api/pages/{_host_page_id(gone)}/trash")

    r = client.get("/db", cookies={"flowdeck_workspace": str(ws)})
    assert r.status_code == 200, r.text
    assert "Vivante" in r.text
    assert "Supprimée" not in r.text


def test_db_page_lists_other_workspaces_too(client):
    """Le workspace actif d'abord, les autres dans une section séparée : sans
    cela, un workspace sans base affichait une page vide alors que l'utilisateur
    en possédait ailleurs."""
    a = _make_workspace(client, "WS-A")
    b = _make_workspace(client, "WS-B")
    _database(client, a, "Base A")
    _database(client, b, "Base B")

    body = client.get("/db", cookies={"flowdeck_workspace": str(b)}).text
    assert "Base B" in body
    # Base A reste atteignable, mais signalée comme appartenant à un autre
    # workspace — elle alimente My Tasks comme les autres.
    assert "Base A" in body
    assert "Autres workspaces" in body


def test_db_page_shows_workspace_name_and_mine_first(client):
    a = _make_workspace(client, "Mon espace")
    b = _make_workspace(client, "Autre espace")
    _database(client, a, "La mienne")
    _database(client, b, "La sienne")

    body = client.get("/db", cookies={"flowdeck_workspace": str(a)}).text
    assert "Mon espace" in body
    assert body.index("La mienne") < body.index("La sienne")


def test_db_page_requires_a_session(client):
    """Sans session, `/db` redirige vers la page de connexion (et non pas une
    liste de bases)."""
    from conftest import anon

    resp = anon(client).get("/db", follow_redirects=False)
    assert resp.status_code in (302, 303), resp.status_code
    assert "/auth/login" in resp.headers.get("location", "")


def test_empty_my_tasks_points_at_the_right_button(client):
    """L'invite doit désigner le bouton qui existe réellement."""
    ws = _make_workspace(client, "Invite")
    _database(client, ws, "Backlog", rows=1)

    r = client.get("/my-tasks")
    assert r.status_code == 200, r.text
    assert "Convertir en base de tâches" in r.text
    # L'ancien texte renvoyait vers un bouton « … » qui n'existe pas.
    assert "bouton « … »" not in r.text
    assert "à gauche du bouton" in r.text


def test_database_view_bar_exposes_the_conversion_button(client):
    """Le bouton est dans la barre de la base, avec un libellé lisible."""
    r = client.get("/static/js/database_table.js")
    assert r.status_code == 200, r.text
    assert "db-task-db-btn" in r.text
    assert "Convertir en base de tâches" in r.text
    assert "data-task-db=" in r.text


# ── Bases portées par une page (`collections.workspace_id` NULL) ──────────
#
# Une base née d'un document (page convertie en base, base inline, base liée
# ou modèle importé) a `workspace_id` NULL et n'appartient qu'à travers sa page
# hôte. Filtrer sur `workspace_id`, ou joindre `workspaces` sans repli, la
# faisait disparaître de `/db` **et** de My Tasks : l'utilisateur voyait sa base
# dans la sidebar mais n'arrivait plus à la convertir ni à la retrouver.


def _page_hosted_database(ws_id: int, name: str, *, rows: int = 0,
                          host_page: bool = True) -> int:
    """Base portée par une page du workspace, `workspace_id` laissé NULL."""
    page = _host_page(ws_id, name) if host_page else None
    with _conn() as conn:
        cid = conn.execute(
            "INSERT INTO collections (name, workspace_id, parent_page_id) VALUES (?, NULL, ?)",
            (name, page),
        ).lastrowid
        if page:
            conn.execute(
                "UPDATE pages SET content_format='collection', collection_id=? WHERE id=?",
                (cid, page),
            )
        conn.commit()
    if rows:
        with _conn() as conn:
            for i in range(rows):
                conn.execute(
                    "INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)",
                    (cid, f"{name} #{i + 1}"),
                )
            conn.commit()
    return cid


def test_db_page_lists_page_hosted_database(client):
    """Une base à `workspace_id` NULL est bien listée dans son workspace."""
    ws = _make_workspace(client, "Portée par page")
    _page_hosted_database(ws, "Base portée", rows=2)

    body = client.get("/db", cookies={"flowdeck_workspace": str(ws)}).text
    assert "Base portée" in body
    assert "Aucune base dans ce workspace" not in body


def test_db_page_shows_page_hosted_base_of_another_workspace(client):
    """Workspace actif vide + base portée par une page ailleurs : la section
    « Autres workspaces » la remonte, avec le nom du workspace d'origine."""
    mine = _make_workspace(client, "Actif")
    other = _make_workspace(client, "Elsewhere")
    _page_hosted_database(other, "Base ailleurs")

    body = client.get("/db", cookies={"flowdeck_workspace": str(mine)}).text
    assert "Base ailleurs" in body
    assert "Autres workspaces" in body
    # Le workspace d'appartenance est indiqué (l'en-tête ne montre que l'actif).
    assert "Elsewhere" in body


def test_page_hosted_task_database_feeds_my_tasks(client):
    """Le bout en bout du signalement : une base portée par une page, convertie
    en base de tâches, doit alimenter My Tasks."""
    ws = _make_workspace(client, "Émission")
    cid = _page_hosted_database(ws, "Backlog page")
    _connect(client, cid)
    _row(cid, "Écrire le rapport", user=_user())

    payload = client.get("/my-tasks/api").json()
    assert payload["total"] == 1, payload
    assert payload["tasks"][0]["title"] == "Écrire le rapport"
    assert payload["sources_total"] == 1
    # La source est attribuée à son workspace effectif, pas à `NULL`.
    assert payload["sources"][0]["workspace_name"] == "Émission"


def test_task_database_of_shared_workspace_is_listed(client):
    """Un workspace dont l'utilisateur est **membre** (pas propriétaire) doit
    alimenter ses bases : le périmètre est `owner_id` ∪ `workspace_members`."""
    mine = _make_workspace(client, "Mien")
    shared = _make_workspace(client, "Partagé")
    with _conn() as conn:
        conn.execute(
            "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) "
            "VALUES (?, ?, 'editor')",
            (shared, _user()["id"]),
        )
        conn.commit()

    cid = _page_hosted_database(shared, "Base partagée")
    _connect(client, cid)

    sources = client.get("/db/task-dbs/api").json()
    assert [s["name"] for s in sources["sources"]] == ["Base partagée"]
    assert "Base partagée" in client.get(
        "/db", cookies={"flowdeck_workspace": str(mine)}
    ).text


def _other_workspace(owner_id: int, name: str) -> int:
    with _conn() as conn:
        ws_id = conn.execute(
            "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
            (name, owner_id),
        ).lastrowid
        conn.commit()
    return ws_id


def test_db_page_hides_bases_of_another_user(client):
    """Isolation : la liste « Autres workspaces » ne doit exposer que les
    workspaces de l'appelant."""
    from conftest import login_test_client
    from fastapi.testclient import TestClient

    from app.main import app

    # La session (et donc la ligne `users`) doit exister avant d'attribuer un
    # workspace à cet utilisateur : `workspaces.owner_id` est une clé étrangère.
    stranger = login_test_client(
        TestClient(app), user_id=4242, login="stranger", is_admin=0
    )
    their_ws = _other_workspace(4242, "Espace inconnu")
    their_cid = _page_hosted_database(their_ws, "Base inconnue")

    mine = _make_workspace(client, "Mien")
    _page_hosted_database(mine, "Ma base")

    assert "Ma base" in client.get("/db").text

    body = stranger.get("/db").text
    assert "Base inconnue" in body          # la sienne, bien
    assert "Ma base" not in body             # pas celle d'autrui
    assert "Mien" not in body
    assert stranger.get("/db/task-dbs/api").json()["total"] == 0

    # Même verdict côté My Tasks : aucune source étrangère.
    assert stranger.get("/db/task-dbs/api").json()["sources"] == []
    assert their_cid != mine


def test_db_page_skips_database_without_any_workspace(client):
    """Base sans `workspace_id` **et** sans page hôte : elle n'appartient à
    personne, donc à personne ne doit la voir (une liste « toutes les bases »
    de l'instance l'afficherait à tout le monde)."""
    ws = _make_workspace(client, "Seul")
    with _conn() as conn:
        conn.execute(
            "INSERT INTO collections (name, workspace_id, parent_page_id) "
            "VALUES ('Base orpheline', NULL, NULL)"
        )
        conn.commit()

    body = client.get("/db", cookies={"flowdeck_workspace": str(ws)}).text
    assert "Base orpheline" not in body


def test_page_hosted_database_of_deleted_page_is_hidden(client):
    """Le repli sur la page hôte ne doit pas ressusciter une base dont le
    document a été supprimé."""
    ws = _make_workspace(client, "Coroutine")
    cid = _page_hosted_database(ws, "Base morte")
    with _conn() as conn:
        conn.execute(
            "UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id="
            "(SELECT parent_page_id FROM collections WHERE id=?)",
            (cid,),
        )
        conn.commit()

    assert "Base morte" not in client.get(
        "/db", cookies={"flowdeck_workspace": str(ws)}
    ).text


# ══════════════════════════════════════════════════════════════════════════
# v7.47.1 — Rendu « non connectée », icône de ligne et navigation interne
# ══════════════════════════════════════════════════════════════════════════

def _my_tasks_js() -> str:
    from pathlib import Path

    return Path("static/js/my_tasks.js").read_text(encoding="utf-8")


def _my_tasks_css() -> str:
    from pathlib import Path

    return Path("static/css/my_tasks.css").read_text(encoding="utf-8")


def test_disconnected_state_still_links_the_stylesheet(client):
    """Aucune base connectée : l'invite doit être **centrée**.

    Régression : la feuille de style était liée par le même mini-template que
    le script, et ce dernier était conditionné à ``app_js``. Sans base, la
    page perdait donc sa feuille de style et tout retombait à gauche.
    """
    body = client.get("/my-tasks").text

    assert "my_tasks.css" in body, "la feuille de style doit TOUJOURS être liée"
    # Gabarit centré par la feuille de style, pas par du style inline.
    assert "my-tasks-empty" in body
    assert 'style="text-align:center"' not in body
    # Aucun tableau de bord client à monter : le script reste inutile.
    assert "my_tasks.js" not in body


def test_no_workspace_state_still_links_the_stylesheet(client):
    """Même exigence pour l'état « aucun workspace » (même gabarit)."""
    body = client.get("/my-tasks").text

    assert "Aucun workspace" in body
    assert "my_tasks.css" in body
    assert "my_tasks.js" not in body


def test_connected_state_links_stylesheet_and_script(client):
    """Dès qu'une base alimente la vue, CSS + JS sont tous deux liés."""
    ws = _make_workspace(client, "Connectée")
    cid = _page_hosted_database(ws, "Base")
    _connect(client, cid)

    body = client.get("/my-tasks").text
    assert 'id="my-tasks-app"' in body
    assert "my_tasks.css" in body
    assert "my_tasks.js" in body


def test_my_tasks_js_does_not_use_a_one_shot_window_guard():
    """La navigation interne rejoue le