.peek-body gardait padding:16px 20px + overflow-y:auto (heritage de l'ancien peek a rendu HTML) : l'iframe etait amochee (519x816 mesures contre 559x848 en local-workspace) avec bande grise autour du document + double scrollbar. Corps nu (padding:0, overflow:hidden) : l'editeur remplit le panneau, exactement comme le peek de /local-workspace. Mesure Playwright (cookie de session forge, les 14 boutons Open de Library) : embed OK partout, bases hydratees, aucun jeton « Loading database... ». Test: test_library_peek_body_has_no_inset_padding. Suite 1266/0 (-n auto) + ruff 0. Bump VERSION/main.py 7.50.2, CHANGELOG, WORKLOAD, OpenAPI.
1588 lines
61 KiB
Python
1588 lines
61 KiB
Python
"""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 "<pre>" 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 <script> sans recharger le document.
|
||
|
||
Un garde global (`window.__fdMyTasksLoaded`) faisait sortir le IIFE au
|
||
retour sur la page : `#my-tasks-app` restait vide jusqu'au hard refresh.
|
||
Le montage doit être rejouable et porté par l'élément.
|
||
"""
|
||
src = _my_tasks_js()
|
||
|
||
assert "__fdMyTasksLoaded" not in src
|
||
assert "htmx:afterSwap" in src, "le remontage doit suivre les swaps htmx"
|
||
assert "popstate" in src, "le bouton « retour » du navigateur doit remonter"
|
||
|
||
|
||
def test_my_tasks_js_hides_the_legacy_file_icon_sentinel():
|
||
"""`collection_pages.icon = 'file'` = « pas d'icône » (cf. database_table.js).
|
||
|
||
Affiché tel quel, le mot « file » apparaissait devant chaque titre.
|
||
"""
|
||
src = _my_tasks_js()
|
||
|
||
assert "function rowIcon(" in src
|
||
assert "=== 'file'" in src
|
||
assert "esc(t.icon)" not in src, "l'icône brute ne doit plus être échappée"
|
||
|
||
|
||
def test_my_tasks_js_opens_the_row_content_page():
|
||
"""Chaque tâche du tableau doit ouvrir sa page de contenu.
|
||
|
||
Un id de ligne n'est pas un id de page : naviguer vers
|
||
``/pages/{rowId}`` ouvrirait une page sans rapport (cf.
|
||
``database_table.js``). Le repli doit donc rester sur le tableau de bord.
|
||
"""
|
||
src = _my_tasks_js()
|
||
|
||
assert "data-open-row" in src
|
||
assert "/open/api" in src
|
||
assert "'/pages/' + rowId" not in src, "ne jamais ouvrir un id de ligne"
|
||
|
||
|
||
def test_task_title_cell_is_padded():
|
||
"""La cellule « Tâche » a une marge interne : le titre ne colle pas au bord."""
|
||
css = _my_tasks_css()
|
||
cell = css.split(".mt-cell {")[1].split("}")[0]
|
||
assert "padding" in cell
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════
|
||
# v7.48.0 — Parité Library (side peek, survol, layout) + robustesse navigation
|
||
# ══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_task_rows_use_library_hover_only_open_button():
|
||
"""Chaque tâche porte le bouton « Open » de Library, révélé au survol.
|
||
|
||
Bibliothèque de référence : ``.btn-open`` / ``.hover-only`` dans
|
||
``app/templates/library.html``.
|
||
"""
|
||
js = _my_tasks_js()
|
||
css = _my_tasks_css()
|
||
|
||
assert "mt-open-btn" in js and "mt-hover-only" in js
|
||
# Le libellé et l'icone sont ceux de Library.
|
||
assert 'title="Open in side peek"' in js
|
||
assert "> Open</button>" in js
|
||
# Masque tant que la ligne n'est pas survolee.
|
||
hover = css.split(".mt-row:hover .mt-hover-only")[1]
|
||
assert "opacity: 1" in hover
|
||
|
||
|
||
def test_task_rows_are_divs_not_a_table():
|
||
"""Lignes en `div`, comme Library : c'est ce qui permet le survol."""
|
||
js = _my_tasks_js()
|
||
assert "<table" not in js, "les lignes doivent être des div (parité Library)"
|
||
assert 'class="mt-row"' in js
|
||
assert 'class="mt-cell mt-cell-name"' in js
|
||
|
||
|
||
def test_task_content_opens_in_a_side_peek_panel():
|
||
"""Le contenu s'ouvre dans un panneau latéral (pas une navigation)."""
|
||
js = _my_tasks_js()
|
||
css = _my_tasks_css()
|
||
|
||
assert "mt-peek" in js and "openRowContent" in js
|
||
assert "?embed=1" in js, "le panneau charge l'éditeur comme le peek de Library"
|
||
# Le panneau est cree a la demande et vit hors de #my-tasks-app, sinon
|
||
# render() (filtres) le detruirait.
|
||
assert "document.body.appendChild" in js
|
||
# Fermeture par Echap, comme dans Library.
|
||
assert "Escape" in js
|
||
assert ".mt-peek" in css
|
||
|
||
|
||
def test_stale_overlay_and_peek_are_closed_on_mount():
|
||
"""Modale / panneau restés ouverts masqueraient la page suivante."""
|
||
js = _my_tasks_js()
|
||
assert "mt-modal-overlay" in js
|
||
assert "closePeek()" in js
|
||
|
||
|
||
def test_my_tasks_mounts_again_if_left_empty():
|
||
"""Coquille montée mais vide = swap interrompu : on remonte."""
|
||
js = _my_tasks_js()
|
||
assert "app.__fdMounted && app.innerHTML.trim()" in js
|
||
# Les delegations d'evenements ne sont posees qu'une fois par noeud.
|
||
assert "app.__fdWired" in js
|
||
assert js.count("if (wire) app.addEventListener") == 2
|
||
|
||
|
||
def test_my_tasks_stylesheet_is_linked_by_the_shell():
|
||
"""`my_tasks.css` doit etre lie par base.html, pas par la zone swappee.
|
||
|
||
Un ``<link>`` a l'interieur de ``.main-wrapper`` peut etre retire par htmx
|
||
lors d'une navigation partielle : la page revenait sans feuille de style
|
||
(composants blancs en theme sombre).
|
||
"""
|
||
from pathlib import Path
|
||
|
||
shell = Path("app/templates/base.html").read_text(encoding="utf-8")
|
||
head = shell.split("</head>")[0]
|
||
assert "/static/css/my_tasks.css" in head
|
||
|
||
|
||
def test_spa_navigation_is_never_silently_dropped():
|
||
"""`fdLoad` ne doit plus ignorer un clic quand une navigation est en vol.
|
||
|
||
Le clic etait perdu sans changer ni l'URL ni le contenu ; et si la
|
||
promesse htmx ne se resolvait pas, ``loading`` restait a ``true`` et plus
|
||
aucune navigation ne repartait (page figee).
|
||
"""
|
||
from pathlib import Path
|
||
|
||
src = Path("static/js/app.js").read_text(encoding="utf-8")
|
||
fd_load = src.split("function fdLoad(")[1].split("\n }")[0]
|
||
|
||
assert "if (loading) return;" not in fd_load
|
||
assert "if (loading) { window.location.href = url; return; }" in fd_load
|
||
assert "LOAD_WATCHDOG_MS" in fd_load, "loading ne doit pas pouvoir rester bloque"
|
||
|
||
|
||
def test_swap_waits_only_for_its_own_scripts():
|
||
"""Le compteur de scripts ne doit reacting qu'aux scripts de la zone swappee.
|
||
|
||
``load`` en capture sur ``document`` voit TOUS les <script> de la page :
|
||
compter les autres faisait monter l'arbre Alpine avant que les scripts de
|
||
la page n'aient tourne.
|
||
"""
|
||
from pathlib import Path
|
||
|
||
src = Path("static/js/app.js").read_text(encoding="utf-8")
|
||
on_script = src.split("var onScript = function (e) {")[1].split("};")[0]
|
||
|
||
assert "scripts.indexOf(node) === -1" in on_script
|
||
|
||
|
||
def test_editor_has_ctrl_s_save_shortcut():
|
||
"""Ctrl/Cmd + S sauvegarde le document en cours d'edition."""
|
||
from pathlib import Path
|
||
|
||
src = Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
|
||
block = src.split("Ctrl/Cmd + S")[1]
|
||
|
||
assert "e.ctrlKey || e.metaKey" in block
|
||
assert "E.save(notify)" in block
|
||
assert "e.preventDefault()" in block, "sinon le navigateur ouvre « Enregistrer la page »"
|
||
# `window.E` survit aux navigations partielles : on exige que l'editeur
|
||
# soit reellement monte, sinon Ctrl+S sauvegarderait un editeur detache.
|
||
assert "document.getElementById('_blocksCt')" in block
|
||
assert "E.canEdit === false" in block, "une page verrouillee ne doit pas ecrire"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════
|
||
# v7.49.0 — Side peek : même éditeur que le plein écran, calendrier à 5 vues
|
||
# ══════════════════════════════════════════════════════════════════════════
|
||
|
||
def _library_html() -> str:
|
||
from pathlib import Path
|
||
|
||
return Path("app/templates/library.html").read_text(encoding="utf-8")
|
||
|
||
|
||
def _library_js() -> str:
|
||
from pathlib import Path
|
||
|
||
return Path("static/js/library.js").read_text(encoding="utf-8")
|
||
|
||
|
||
def _strip_js_comments(src: str) -> str:
|
||
r"""Retire commentaires de bloc, de ligne et HTML.
|
||
|
||
Les trois contiennent des occurrences de `:src` / `?embed=1` dans des
|
||
phrases explicatives : on ne teste donc que le code réellement exécuté.
|
||
"""
|
||
src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S)
|
||
src = re.sub(r"(?m)^\s*//.*$", " ", src)
|
||
return re.sub(r"<!--.*?-->", " ", src, flags=re.S)
|
||
|
||
|
||
def _js_func(src: str, header: str) -> str:
|
||
"""Corps d'une fonction JavaScript, délimité par comptage d'accolades.
|
||
|
||
Évite les tranches par `split`, qui tombaient sur le *site d'appel*
|
||
(`this.openPeek(item)`) au lieu de la définition.
|
||
"""
|
||
i = src.index(header)
|
||
j = src.index("{", i)
|
||
depth, k = 0, j
|
||
while True:
|
||
if src[k] == "{":
|
||
depth += 1
|
||
elif src[k] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return src[j:k + 1]
|
||
k += 1
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════
|
||
# v7.49.0 — Side peek : même éditeur que le plein écran, calendrier à 5 vues
|
||
# ══════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_library_peek_iframe_is_not_inside_the_x_if_template():
|
||
"""L'iframe doit survivre au changement de document et être devoid de
|
||
toute expression Alpine : dans `<template x-if>` comme avec `:src` sous
|
||
le build CSP, elle restait vide et aucun document n'apparaissait."""
|
||
html = _strip_js_comments(_library_html())
|
||
|
||
assert ":src" not in html, "plus de binding :src (incompatible build CSP)"
|
||
assert 'id="lib-peek-iframe"' in html
|
||
at = html.index('id="lib-peek-iframe"')
|
||
assert at > html.index("</template>"), "l'iframe doit être hors du x-if"
|
||
|
||
|
||
def test_library_peek_loads_the_embed_editor_only():
|
||
"""Le peek charge `/pages/{id}?embed=1` : le document en mode édition
|
||
SEUL — le cadre du panneau suffit, ni sidebar ni barre de navigation."""
|
||
body = _js_func(_library_js(), "openPeek(item) {")
|
||
|
||
assert "frame.src = '/pages/' + item.id + '?embed=1'" in body
|
||
|
||
|
||
def test_library_peek_body_has_no_inset_padding():
|
||
"""Le corps du peek de Library est nu (padding 0, overflow hidden) :
|
||
l'iframe doit remplir le panneau comme dans local-workspace, sans double
|
||
scrollbar ni bande grise autour du document."""
|
||
from pathlib import Path
|
||
|
||
html = Path("app/templates/library.html").read_text(encoding="utf-8")
|
||
rule = html.split(".peek-body{")[1].split("}")[0]
|
||
assert "padding:0" in rule
|
||
assert "overflow-y:auto" not in rule
|
||
|
||
|
||
def test_every_side_peek_loads_the_embed_editor_only():
|
||
"""Library, My Tasks, bases et workspace local : le peek ne montre QUE le
|
||
document éditable (`?embed=1`), jamais l'habillage pleine page."""
|
||
from pathlib import Path
|
||
|
||
for name in ("library.js", "my_tasks.js", "database_table.js",
|
||
"local_workspace.js"):
|
||
src = _strip_js_comments((Path("static/js") / name).read_text(encoding="utf-8"))
|
||
assert "?embed=1" in src, name
|
||
|
||
|
||
def test_library_peek_reloads_when_switching_documents():
|
||
"""Ouvrir un autre document ferme l'iframe avant de recharger.
|
||
|
||
Sans le passage par `about:blank`, la même URL ne se recharge pas et le
|
||
document précédent reste affiché.
|
||
"""
|
||
body = _js_func(_library_js(), "openPeek(item) {")
|
||
|
||
assert body.count("frame.src =") == 2, "fermeture puis rechargement"
|
||
assert "frame.src = 'about:blank'" in body
|
||
assert body.index("about:blank") < body.index("'/pages/' + item.id")
|
||
|
||
|
||
def test_close_peek_unloads_the_editor_iframe():
|
||
"""Fermer le peek décharge l'éditeur : sinon il reste en mémoire et son
|
||
autosave continue de tourner alors qu'il est invisible."""
|
||
body = _js_func(_library_js(), "closePeek() {")
|
||
|
||
assert "frame.src = 'about:blank'" in body
|
||
assert "frame.onload = null" in body
|
||
|
||
|
||
def test_calendar_offers_five_views():
|
||
"""Mois / Année / Semaine / Jour / Planning."""
|
||
js = _my_tasks_js()
|
||
|
||
for view in ("month", "year", "week", "day", "planning"):
|
||
assert f"['{view}'," in js, f"vue calendrier manquante : {view}"
|
||
for fn in ("calMonth", "calYear", "calWeek", "calDay", "calPlanning"):
|
||
assert f"function {fn}(" in js, fn
|
||
assert "function calShift(" in js, "navigation propre à chaque granularité"
|
||
|
||
|
||
def test_calendar_events_have_a_side_peek_icon():
|
||
"""Chaque tâche du calendrier porte son icône d'ouverture, avec le même
|
||
attribut de ligne que le tableau : un seul gestionnaire de clic."""
|
||
body = _js_func(_my_tasks_js(), "function calEvHtml(")
|
||
|
||
assert "mt-cal-open" in body
|
||
assert 'title="Open in side peek"' in body
|
||
assert "data-open-row=" in body
|
||
assert "data-open-title=" in body
|
||
|
||
|
||
def test_open_buttons_resolve_with_closest_not_target():
|
||
"""Le gestionnaire de clic lit `data-open-row` via `closest` : les boutons
|
||
du calendrier et du Kanban ne contiennent que leur SVG — un clic sur
|
||
l'icone (e.target = <svg>, sans dataset) doit ouvrir le peek aussi."""
|
||
js = _my_tasks_js()
|
||
i = js.index("app.addEventListener('click'")
|
||
block = js[i:i + 900]
|
||
assert "closest('[data-open-row]')" in block
|
||
|
||
|
||
def test_kanban_cards_have_the_open_button():
|
||
"""Les tuiles du Kanban portent le même bouton « Open » que les lignes
|
||
du tableau (parité avec la page d'origine : même peek latéral)."""
|
||
body = _js_func(_my_tasks_js(), "function viewBoard(")
|
||
|
||
assert "openButton(t)" in body
|
||
# Le bouton est révélé au survol de la tuile (overlay, la carte est une
|
||
# colonne flex — pas de place en flux).
|
||
css = _my_tasks_css()
|
||
assert ".mt-card:hover .mt-hover-only" in css
|
||
|
||
|
||
def test_base_page_collection_renders_the_table_when_embedded():
|
||
"""`/pages/{id}?embed=1` sur une page de base garde le TABLEAU (template
|
||
collection) — le peek d'une base ouvre le tableau sans sidebar ni barre,
|
||
pas l'éditeur de blocs vide."""
|
||
from pathlib import Path
|
||
|
||
src = Path("app/routers/dashboard/pages_html.py").read_text(encoding="utf-8")
|
||
i = src.index('content_format") == "collection"')
|
||
branch = src[i:i + 220]
|
||
assert "not embed" not in branch.split("template")[0]
|
||
|
||
|
||
def test_embed_editor_can_render_inline_databases():
|
||
"""Le peek (`page_editor_embed.html`) doit charger database_table.js :
|
||
sans lui, une base embarquée restait bloquée à « Loading database… » et
|
||
l'icône Open de fin de ligne ne faisait rien. Le panneau #db-side-peek
|
||
est créé à la demande par database_table.js (markup unique)."""
|
||
from pathlib import Path
|
||
|
||
html = Path("app/templates/page_editor_embed.html").read_text(encoding="utf-8")
|
||
assert "_database_table_scripts.html" in html
|
||
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
|
||
assert "ensureDbPeek" in js
|
||
# Pas de second markup statique du panneau (source unique).
|
||
tbl = Path("app/templates/_database_table.html").read_text(encoding="utf-8")
|
||
assert 'id="db-side-peek"' not in tbl
|
||
|
||
|
||
def test_open_button_carries_its_title_as_attribute():
|
||
"""Le titre est lu depuis `dataset.openTitle` : disponible aussi dans le
|
||
calendrier et la vue Jour, où il n'y a pas de ligne `.mt-row`."""
|
||
js = _my_tasks_js()
|
||
|
||
assert 'data-open-title="' in _js_func(js, "function openButton(")
|
||
assert "dataset.openTitle" in js
|
||
|
||
|
||
def test_db_side_peek_never_falls_back_to_the_row_id():
|
||
"""Un id de ligne de base n'est PAS un id de page.
|
||
|
||
Le repli `return rowId` ouvrait `/pages/{rowId}`, c'est-à-dire une page
|
||
sans rapport portant le même identifiant — exactement le piège documenté
|
||
quelques lignes plus haut dans ce fichier. En cas d'échec on remonte
|
||
l'erreur au lieu d'ouvrir le mauvais document.
|
||
"""
|
||
from pathlib import Path
|
||
|
||
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
|
||
body = _js_func(js, "function resolveRowContentPage(")
|
||
|
||
assert "return rowId" not in body, "repli interdit sur l'id de ligne"
|
||
assert "throw new Error" in body, "un échec doit être signalé"
|
||
|
||
caller = js[js.index("resolveRowContentPage(pageId)"):][:1400]
|
||
assert "'/pages/'+pid" in caller, "seule une page résolue est chargée"
|
||
assert ".catch(" in caller, "l'appelant doit traiter l'échec"
|
||
assert "ouvrir le contenu de cette ligne" in caller, "erreur remontée"
|
||
|
||
|
||
def test_peek_iframes_never_start_empty():
|
||
"""Une iframe `src=""` se résout en l'URL courante.
|
||
|
||
Le navigateur chargeait donc une copie complète de la page dans l'iframe
|
||
cachée, et `frame.src = ''` à la fermeture rechargeait la page entière
|
||
dans le panneau. Les deux doivent valoir `about:blank`.
|
||
"""
|
||
from pathlib import Path
|
||
|
||
for name in ("_database_table.html", "library.html", "base.html"):
|
||
html = (Path("app/templates") / name).read_text(encoding="utf-8")
|
||
for tag in re.findall(r"<iframe[^>]*>", html):
|
||
assert 'src=""' not in tag, f"{name} : {tag[:80]}"
|
||
|
||
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
|
||
assert "iframe').src=''" not in js, "fermeture : src='' recharge la page"
|
||
assert "db-side-peek-iframe').src='about:blank'" in js
|
||
|
||
|
||
def test_db_side_peek_reports_resolution_failure_in_the_panel():
|
||
"""L'échec doit être VISIBLE, pas seulement journalisé.
|
||
|
||
Sous le build CSP d'Alpine aucun composant n'est initialisé
|
||
(`Alpine.$data(el)` ne renvoie aucune clé), donc `window.showToast` — qui
|
||
lit `el.__x`, propriété du build de développement — reste sans effet. Le
|
||
message est donc rendu dans le panneau via `srcdoc`, sans dépendance.
|
||
"""
|
||
from pathlib import Path
|
||
|
||
js = Path("static/js/database_table.js").read_text(encoding="utf-8")
|
||
# On isole le bloc `.catch(...)` qui suit l'appel : le panneau doit y être
|
||
# affiché APRÈS que l'erreur y soit injectée, sinon l'utilisateur voit un
|
||
# cadre vide. On part de l'appel pour ne pas attraper un autre `.catch`.
|
||
tail = js[js.index("resolveRowContentPage(pageId)"):]
|
||
block = _js_func(tail, ".catch(function(){")
|
||
|
||
assert "srcdoc" in block, "erreur rendue dans le panneau"
|
||
assert "Impossible d’ouvrir le contenu" in block
|
||
# Le panneau (révélé par `classList.add('open')` depuis la réécriture
|
||
# vanilla JS de v7.49.0) doit s'afficher APRÈS l'injection du message.
|
||
assert block.index("srcdoc") < block.index("classList.add('open')")
|