fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
Corrige deux régressions de /local-workspace :
- le chemin du header restait bloqué sur « Home / <workspace> » quel que
soit le dossier affiché : la route rend désormais breadcrumb_items
(Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
fois : Alpine.data('wsInitData') retournait le même objet singleton, le
2e montage (navigation partielle) levait « Cannot redefine property:
\ » et initTree abandonnait, laissant tout le contenu au state
brut. La factory retourne désormais une enveloppe fraîche par montage
qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
exécution (le 2e montage gardait le folder_id du 1er chargement).
Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
"""FlowDeck v7.69.6 — Library AI Meeting Notes, Recents sidebar, Publish unifié.
|
||||
|
||||
- ``GET /api/library/meetings`` : notes de réunion groupées par date côté
|
||||
client ; exclues des onglets recents/workspace/private.
|
||||
- Sidebar Recents : fichiers réellement récents (``_recent_sidebar_pages``),
|
||||
icônes workspace, notes de réunion exclues.
|
||||
- Publish : ``is_published`` + ``published`` maintenus ensemble — la page
|
||||
publiée apparaît dans la sidebar Published, la Library et le dialogue.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from conftest import anon_csrf # noqa: F401
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
def _login(client):
|
||||
from app.auth.session import SessionManager
|
||||
login = f"v696_{secrets.token_hex(4)}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'V696', ?, 0)",
|
||||
(login, f"{login}@test.com"),
|
||||
)
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
||||
conn.commit()
|
||||
return SessionManager.create_session({"id": uid, "login": login}), uid
|
||||
|
||||
|
||||
def _cookies(session):
|
||||
return {"flowdeck_session": session}
|
||||
|
||||
|
||||
def _mkworkspace(uid, name="WS696"):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
|
||||
(name, uid))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _mkpage(ws_id, title, with_meeting=False, updated="2026-10-09 12:00:00"):
|
||||
content = json.dumps([{"id": "b1",
|
||||
"type": "meeting" if with_meeting else "paragraph",
|
||||
"content": ""}])
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format,"
|
||||
" parent_section, updated_at) VALUES ('t', ?, ?, ?, 'blocks', 'Private', ?)",
|
||||
(ws_id, title, content, updated))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ── Library meetings ─────────────────────────────────────────────────────
|
||||
|
||||
def test_library_meetings_tab_lists_only_meeting_notes(client):
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
mp = _mkpage(ws, "Sprint Review", True)
|
||||
_mkpage(ws, "Plain notes", False)
|
||||
r = client.get("/api/library/meetings", cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert [i["id"] for i in items] == [mp]
|
||||
assert items[0]["is_meeting"] is True
|
||||
assert items[0]["url"] == f"/pages/{mp}"
|
||||
|
||||
|
||||
def test_library_browsing_tabs_hide_meeting_notes(client):
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
_mkpage(ws, "Sprint Review", True, updated="2026-10-09 12:00:00")
|
||||
plain = _mkpage(ws, "Plain notes", False, updated="2026-10-09 13:00:00")
|
||||
for tab in ("recents", "workspace", "private"):
|
||||
r = client.get(f"/api/library/{tab}", cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
ids = [i["id"] for i in r.json()["items"]]
|
||||
assert plain in ids
|
||||
assert not any(i.get("is_meeting") for i in r.json()["items"])
|
||||
assert len([i for i in ids if isinstance(i, int)]) == len(ids)
|
||||
|
||||
|
||||
# ── Sidebar recents ──────────────────────────────────────────────────────
|
||||
|
||||
def test_recent_sidebar_pages_excludes_meetings_and_uses_workspace_icons(client):
|
||||
from app.routers.dashboard._common import _recent_sidebar_pages
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
_mkpage(ws, "Sprint Review", True)
|
||||
plain = _mkpage(ws, "Plain notes", False)
|
||||
recents = _recent_sidebar_pages(uid)
|
||||
ids = [e["id"] for e in recents]
|
||||
assert f"page/{plain}" in ids
|
||||
assert not any("Sprint" in e["name"] for e in recents)
|
||||
icons = {e["id"]: e["icon"] for e in recents}
|
||||
assert icons[f"page/{plain}"] == "edit" # même convention que le Workspace
|
||||
assert all(e["url"].startswith("/pages/") for e in recents)
|
||||
|
||||
|
||||
# ── Publish unifié ───────────────────────────────────────────────────────
|
||||
|
||||
def test_publish_sets_both_flags_and_lists(client):
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
pid = _mkpage(ws, "Launch notes", False)
|
||||
r = client.post(f"/api/pages/{pid}/publish", cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["is_published"] is True
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_published, published, publish_slug FROM pages"
|
||||
" WHERE id=?", (pid,)).fetchone()
|
||||
assert row["is_published"] == 1 and row["published"] == 1
|
||||
assert row["publish_slug"]
|
||||
# Library Published + sidebar Published voient la page.
|
||||
r = client.get("/api/library/published", cookies=_cookies(session))
|
||||
assert pid in [i["id"] for i in r.json()["items"]]
|
||||
from app.routers.board._common import _load_shared_sidebar_pages
|
||||
_made, _received, published, _shared = _load_shared_sidebar_pages(uid)
|
||||
assert any(p["db_id"] == pid for p in published)
|
||||
# Le dialogue (page_data) reflète l'état.
|
||||
r = client.get(f"/pages/{pid}", cookies=_cookies(session))
|
||||
assert r.status_code == 200
|
||||
assert '"published": true' in r.text
|
||||
# Unpublish nettoie les deux drapeaux.
|
||||
r = client.delete(f"/api/pages/{pid}/publish", cookies=_cookies(session))
|
||||
assert r.json()["is_published"] is False
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_published, published FROM pages WHERE id=?",
|
||||
(pid,)).fetchone()
|
||||
assert row["is_published"] == 0 and row["published"] == 0
|
||||
r = client.get("/api/library/published", cookies=_cookies(session))
|
||||
assert pid not in [i["id"] for i in r.json()["items"]]
|
||||
|
||||
|
||||
def test_share_dialog_publish_path_sets_both_flags_and_slug(client):
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
pid = _mkpage(ws, "Dialog publish", False)
|
||||
r = client.post(f"/board/api/share/{pid}",
|
||||
json={"mode": "private", "published": True},
|
||||
cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_published, published, publish_slug FROM pages"
|
||||
" WHERE id=?", (pid,)).fetchone()
|
||||
assert row["is_published"] == 1 and row["published"] == 1
|
||||
assert row["publish_slug"]
|
||||
r = client.get("/api/library/published", cookies=_cookies(session))
|
||||
assert pid in [i["id"] for i in r.json()["items"]]
|
||||
|
||||
|
||||
def test_customize_panel_above_overlay():
|
||||
from pathlib import Path
|
||||
css = (Path(__file__).resolve().parent.parent
|
||||
/ "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
panel = css.split(".sidebar-customize-panel")[1].split("}")[0]
|
||||
assert "z-index: 999" in panel
|
||||
|
||||
|
||||
def test_library_column_selectors_beat_row_rules():
|
||||
"""Show columns : les règles de visibilité doivent battre
|
||||
``.lib-table-header .th`` et ``.lib-row .row-cell`` (0,2,0) — avant,
|
||||
seul ``:not(.show-editedtime)`` gagnait et seule Last edited time
|
||||
s'affichait/masquait (vérifié en direct au navigateur)."""
|
||||
from pathlib import Path
|
||||
html = (Path(__file__).resolve().parent.parent
|
||||
/ "app" / "templates" / "library.html").read_text(encoding="utf-8")
|
||||
for cls in ("lib-col-author", "lib-col-editedby",
|
||||
"lib-col-source", "lib-col-visited"):
|
||||
assert f".lib-table div.{cls}" in html, cls
|
||||
|
||||
|
||||
# ── v7.69.6b : provenance, badges, favoris ─────────────────────────────────
|
||||
|
||||
def _save_blocks(client, session, pid, title="T"):
|
||||
r = client.post(f"/board/api/pages/{pid}/blocks",
|
||||
json={"title": title,
|
||||
"blocks": [{"id": "b1", "type": "paragraph",
|
||||
"content": "hello"}]},
|
||||
cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_library_provenance_columns(client):
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
pid = _mkpage(ws, "Prov notes", False)
|
||||
_save_blocks(client, session, pid, "Prov notes")
|
||||
r = client.get("/api/library/recents", cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
items = [i for i in r.json()["items"] if i["id"] == pid]
|
||||
assert len(items) == 1
|
||||
it = items[0]
|
||||
assert it["author"], "Created by must resolve (versions, sinon owner)"
|
||||
assert it["edited_by"], "Last edited by must resolve"
|
||||
assert it["source_icon"] in ("file", "link", "external-link")
|
||||
assert it["visited_at"] == "", "not visited yet"
|
||||
# Lecture → visite tracée.
|
||||
assert client.get(f"/pages/{pid}", cookies=_cookies(session)).status_code == 200
|
||||
r = client.get("/api/library/recents", cookies=_cookies(session))
|
||||
it = [i for i in r.json()["items"] if i["id"] == pid][0]
|
||||
assert it["visited_at"], "Last visited time must be tracked on view"
|
||||
|
||||
|
||||
def test_favorites_use_workspace_icons(client):
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
pid = _mkpage(ws, "Fav notes", False)
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,0)",
|
||||
(uid, pid))
|
||||
conn.commit()
|
||||
r = client.get("/api/library/favorites", cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
items = [i for i in r.json()["items"] if i["id"] == pid]
|
||||
assert len(items) == 1
|
||||
assert items[0]["icon"] in ("folder", "file", "edit", "image")
|
||||
|
||||
|
||||
def test_local_workspace_tree_status_flags(client):
|
||||
from app.routers.dashboard._common import _build_tree_children
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
pid = _mkpage(ws, "Flagged notes", False)
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,0)",
|
||||
(uid, pid))
|
||||
conn.execute("UPDATE pages SET is_published=1, published=1, publish_slug='x',"
|
||||
" share_mode='anyone' WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
tree = _build_tree_children(conn, None, ws, uid)
|
||||
nodes = [n for n in tree if n["id"] == pid]
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0]["favorited"] is True
|
||||
assert nodes[0]["published"] is True
|
||||
assert nodes[0]["is_shared"] is True
|
||||
@@ -0,0 +1,328 @@
|
||||
"""FlowDeck v7.1.1 — Meeting block façon Notion (machine à états, consentement,
|
||||
segments, résumé structuré, partage)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
from conftest import anon_csrf # noqa: F401
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
def _login(client):
|
||||
from app.auth.session import SessionManager
|
||||
login = f"v711_{secrets.token_hex(4)}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'V711', ?, 0)",
|
||||
(login, f"{login}@test.com"),
|
||||
)
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
||||
conn.commit()
|
||||
return SessionManager.create_session({"id": uid, "login": login}), uid
|
||||
|
||||
|
||||
def _cookies(session):
|
||||
return {"flowdeck_session": session}
|
||||
|
||||
|
||||
def _mkpage(title="Meeting"):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format)"
|
||||
" VALUES ('test', ?, '', 'blocks')", (title,))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _mktr(client, session, pid=None, **kw):
|
||||
pid = pid or _mkpage()
|
||||
r = client.post("/api/v2/meetings/transcribe",
|
||||
files={"audio": ("rec.mp3", b"ID3" + b"\x00" * 50, "audio/mpeg")},
|
||||
data={"page_id": str(pid), **{k: str(v) for k, v in kw.items()}},
|
||||
cookies=_cookies(session))
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_consent_gate_blocks_start(client):
|
||||
session, _ = _login(client)
|
||||
tid = _mktr(client, session)
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/sessions",
|
||||
json={"mode": "mic_only"}, cookies=_cookies(session))
|
||||
assert r.status_code == 400
|
||||
assert "consent" in r.text.lower()
|
||||
|
||||
|
||||
def test_full_state_machine_offline_degraded(client):
|
||||
session, _ = _login(client)
|
||||
pid = _mkpage("Meeting @Today")
|
||||
tid = _mktr(client, session, pid)
|
||||
# consentement par attestation au démarrage (§3A.2)
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/consents",
|
||||
json={"method": "start_attestation"}, cookies=_cookies(session))
|
||||
assert r.status_code == 200
|
||||
# start avec photo d'instructions
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/sessions",
|
||||
json={"mode": "tab_plus_mic", "language": "fr",
|
||||
"instruction_id": "standup"},
|
||||
cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "recording"
|
||||
# segments groupés par source, marque de troncature (§3A.7)
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/segments",
|
||||
json={"segments": [
|
||||
{"channel": "self", "source_label": "Bruno mic",
|
||||
"speaker": "Bruno", "text": "What I like first is the plan",
|
||||
"start_ms": 0, "end_ms": 2000, "starts_mid": True}]},
|
||||
cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
# pause / resume = états serveur (§6.5)
|
||||
assert client.post(f"/api/v2/meetings/transcripts/{tid}/pause",
|
||||
json={}, cookies=_cookies(session)).status_code == 200
|
||||
assert client.post(f"/api/v2/meetings/transcripts/{tid}/resume",
|
||||
cookies=_cookies(session)).status_code == 200
|
||||
# stop → processing → résumé dégradé classé, titre-diagnostic (§12.8)
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
|
||||
json={"duration_ms": 8000}, cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["completeness"] in ("brief", "incomplete", "fragmentary")
|
||||
assert body["generated_title"] == "Brief or Incomplete Meeting Recording"
|
||||
assert "No action items could be identified" in body["summary"]
|
||||
assert "¹" in body["summary"]
|
||||
# steps Thinking persistés (§12.7)
|
||||
r = client.get(f"/api/v2/meetings/transcripts/{tid}/processing-steps",
|
||||
cookies=_cookies(session))
|
||||
steps = {s["key"]: s["state"] for s in r.json()["steps"]}
|
||||
assert steps.get("resolving_references") == "done"
|
||||
assert steps.get("classifying_completeness") == "done"
|
||||
# transcript groupé par source
|
||||
r = client.get(f"/api/v2/meetings/transcripts/{tid}/transcript",
|
||||
cookies=_cookies(session))
|
||||
assert r.json()["groups"][0]["source"] == "Bruno mic"
|
||||
assert r.json()["groups"][0]["segments"][0]["starts_mid_utterance"] is True
|
||||
# partage tracé (§3A.5)
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/distributions",
|
||||
json={"channel": "copy_link"}, cookies=_cookies(session))
|
||||
assert r.status_code == 200
|
||||
# le titre IA ne touche jamais le titre de page hôte (§3A.5)
|
||||
with get_conn() as conn:
|
||||
title = conn.execute("SELECT title FROM pages WHERE id=?", (pid,)).fetchone()[0]
|
||||
assert title == "Meeting @Today"
|
||||
|
||||
|
||||
def test_complete_meeting_structured_summary(client):
|
||||
session, _ = _login(client)
|
||||
tid = _mktr(client, session)
|
||||
client.post(f"/api/v2/meetings/transcripts/{tid}/consents",
|
||||
json={"method": "verbal"}, cookies=_cookies(session))
|
||||
client.post(f"/api/v2/meetings/transcripts/{tid}/sessions",
|
||||
json={"mode": "mic_only"}, cookies=_cookies(session))
|
||||
words = ("We discussed the pricing launch for september. " * 20
|
||||
+ "Alice will prepare the release notes. "
|
||||
+ "We decided to ship on Friday morning. "
|
||||
+ "Bob should send the invoice before Monday. " * 3)
|
||||
client.post(f"/api/v2/meetings/transcripts/{tid}/segments",
|
||||
json={"segments": [
|
||||
{"channel": "self", "speaker": "Alice", "text": words,
|
||||
"start_ms": 0, "end_ms": 120000}]},
|
||||
cookies=_cookies(session))
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
|
||||
json={"duration_ms": 300000}, cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["completeness"] == "complete"
|
||||
assert "## Action Items" in r.json()["summary"]
|
||||
|
||||
|
||||
def test_unicity_per_occurrence(client):
|
||||
session, _ = _login(client)
|
||||
p1, p2 = _mkpage("A"), _mkpage("B")
|
||||
with get_conn() as conn:
|
||||
t1 = conn.execute(
|
||||
"INSERT INTO meeting_transcripts (page_id, event_occurrence_id)"
|
||||
" VALUES (?, 'google:evt1:2026-10-15')", (p1,)).lastrowid
|
||||
conn.commit()
|
||||
_ = t1
|
||||
import sqlite3
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO meeting_transcripts (page_id, event_occurrence_id)"
|
||||
" VALUES (?, 'google:evt1:2026-10-15')", (p2,))
|
||||
conn.commit()
|
||||
pytest.fail("unicity should reject the duplicate")
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
def test_instructions_and_history(client):
|
||||
session, _ = _login(client)
|
||||
r = client.get("/api/v2/meetings/instructions", cookies=_cookies(session))
|
||||
assert "auto" in [i["id"] for i in r.json()["instructions"]]
|
||||
_mktr(client, session)
|
||||
r = client.get("/api/v2/meetings/history?query=rec",
|
||||
cookies=_cookies(session))
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ── v7.1.1b : arrêt à vide, notes de réunion, arbre Workspace ───────────────
|
||||
|
||||
def _start_empty_session(client, session):
|
||||
pid = _mkpage("Empty take")
|
||||
tid = _mktr(client, session, pid)
|
||||
client.post(f"/api/v2/meetings/transcripts/{tid}/consents",
|
||||
json={"method": "start_attestation"}, cookies=_cookies(session))
|
||||
client.post(f"/api/v2/meetings/transcripts/{tid}/sessions",
|
||||
json={"mode": "mic_only"}, cookies=_cookies(session))
|
||||
return tid
|
||||
|
||||
|
||||
def test_stop_empty_without_flag_is_400_and_recoverable(client):
|
||||
session, _ = _login(client)
|
||||
tid = _start_empty_session(client, session)
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
|
||||
json={}, cookies=_cookies(session))
|
||||
assert r.status_code == 400
|
||||
# La ligne ne reste jamais coincée en « processing ».
|
||||
with get_conn() as conn:
|
||||
status = conn.execute("SELECT status FROM meeting_transcripts WHERE id=?",
|
||||
(tid,)).fetchone()[0]
|
||||
assert status == "failed"
|
||||
|
||||
|
||||
def test_stop_empty_with_flag_gives_diagnostic(client):
|
||||
session, _ = _login(client)
|
||||
tid = _start_empty_session(client, session)
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
|
||||
json={"duration_ms": 5000, "allow_empty": True},
|
||||
cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["completeness"] == "fragmentary"
|
||||
assert body["generated_title"] == "Brief or Incomplete Meeting Recording"
|
||||
assert "No audio was captured" in body["summary"]
|
||||
|
||||
|
||||
def test_retry_from_stuck_processing_with_flag(client):
|
||||
session, _ = _login(client)
|
||||
tid = _start_empty_session(client, session)
|
||||
# Ligne coincée en processing (cas du bug rapporté, transcript id 2).
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE meeting_transcripts SET status='processing' WHERE id=?",
|
||||
(tid,))
|
||||
conn.commit()
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/processing:retry",
|
||||
json={"allow_empty": True}, cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["completeness"] == "fragmentary"
|
||||
|
||||
|
||||
def _mkworkspace(uid, name="WS"):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
|
||||
(name, uid))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _mkblockpage(ws_id, title, with_meeting):
|
||||
import json as _json
|
||||
content = _json.dumps([{"id": "b1", "type": "meeting" if with_meeting else "paragraph",
|
||||
"content": ""}])
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format,"
|
||||
" parent_section) VALUES ('t', ?, ?, ?, 'blocks', 'Private')",
|
||||
(ws_id, title, content))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def test_meeting_notes_endpoint_and_ownership(client):
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
mp = _mkblockpage(ws, "Sprint Review", True)
|
||||
_mkblockpage(ws, "Plain notes", False)
|
||||
# Page d'un autre utilisateur : invisible.
|
||||
s2, uid2 = _login(client)
|
||||
ws2 = _mkworkspace(uid2)
|
||||
_mkblockpage(ws2, "Someone else", True)
|
||||
r = client.get("/api/v2/meetings/notes", cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
ids = [n["id"] for n in r.json()["notes"]]
|
||||
assert mp in ids
|
||||
assert len(ids) == 1
|
||||
assert r.json()["notes"][0]["status"] == "idle"
|
||||
|
||||
|
||||
def test_workspace_tree_excludes_meeting_notes(client):
|
||||
from app.routers.board import _load_workspace_pages
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
_mkblockpage(ws, "Sprint Review", True)
|
||||
_mkblockpage(ws, "Plain notes", False)
|
||||
pages = _load_workspace_pages(str(ws))
|
||||
names = [p["name"] for p in pages]
|
||||
assert "Plain notes" in names
|
||||
assert "Sprint Review" not in names
|
||||
|
||||
|
||||
def test_sidebar_tree_endpoint_keeps_alpine_bindings(client):
|
||||
"""Contrat dont dépendent refreshSidebarTree + Alpine.initTree.
|
||||
|
||||
Régression /local-workspace : le fragment doit porter les bindings
|
||||
(@contextmenu/showWsContext) et exclure les notes de réunion.
|
||||
"""
|
||||
session, uid = _login(client)
|
||||
ws = _mkworkspace(uid)
|
||||
_mkblockpage(ws, "Sprint Review", True)
|
||||
_mkblockpage(ws, "Plain notes", False)
|
||||
cookies = _cookies(session)
|
||||
cookies["flowdeck_workspace"] = str(ws)
|
||||
r = client.get("/api/sidebar/workspace-tree", cookies=cookies)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "showWsContext" in r.text
|
||||
assert "Plain notes" in r.text
|
||||
assert "Sprint Review" not in r.text
|
||||
|
||||
|
||||
# ── v7.69.5 : fin du 400 en boucle ──────────────────────────────────────────
|
||||
|
||||
def test_summarize_empty_with_flag_gives_diagnostic(client):
|
||||
session, _ = _login(client)
|
||||
tid = _mktr(client, session)
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/summarize",
|
||||
json={"allow_empty": True}, cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
assert "No audio was captured" in r.json()["summary"]
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT status, completeness_class FROM meeting_transcripts"
|
||||
" WHERE id=?", (tid,)).fetchone()
|
||||
assert row["status"] == "done"
|
||||
assert row["completeness_class"] == "fragmentary"
|
||||
|
||||
|
||||
def test_topup_after_failure_then_retry(client):
|
||||
"""Le manuel collé après un échec part au retry — plus de 400 en boucle."""
|
||||
session, _ = _login(client)
|
||||
tid = _start_empty_session(client, session)
|
||||
# Premier stop refusé (sans flag) → failed, pas coincé.
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/stop",
|
||||
json={}, cookies=_cookies(session))
|
||||
assert r.status_code == 400
|
||||
# L'utilisateur colle un transcript : top-up autorisé hors session live.
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/segments",
|
||||
json={"segments": [
|
||||
{"channel": "self", "speaker": "Alice",
|
||||
"text": "We decided to ship on Friday morning. Alice will prepare "
|
||||
"the release notes for the team. Bob should send the invoice "
|
||||
"before Monday morning without fail.",
|
||||
"start_ms": 0, "end_ms": 60000}]},
|
||||
cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
r = client.post(f"/api/v2/meetings/transcripts/{tid}/processing:retry",
|
||||
json={}, cookies=_cookies(session))
|
||||
assert r.status_code == 200, r.text
|
||||
assert "Action Items" in r.json()["summary"]
|
||||
Reference in New Issue
Block a user