Files
flowdeck/tests/test_v711_meeting_notion.py
T
bruno 0218d8f5e5
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped
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.
2026-10-09 16:58:04 -04:00

329 lines
14 KiB
Python

"""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"]