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