"""Fusion ACC-6.2 — pilote d'écriture approuvée : archiver un résumé de réunion Flowdeck dans ObsiGate via la file d'approbations unifiée. Chaîne testée : demande déposée dans `agent_approvals` (outil `archiver_reunion_obsigate`) → décision dans la file (route gouvernance) → si approuvée, `propose_write_file` part vers ObsiGate par le pont MCP (ACC-2.2) et la distribution est journalisée (canal `obsigate`). Rejet = rien n'est envoyé. Sans connecteur ObsiGate, l'exécution échoue proprement avec un message explicite. La preuve réelle reste conditionnelle (chantier Meetings A/B + clé ObsiGate dédiée de Bruno) ; ici le serveur MCP est simulé. """ import asyncio import json import pytest from app.auth.session import SessionManager from app.db import get_conn from app.services import mcp_client, meeting_archive, meetings from app.services.importers import url_fetch @pytest.fixture(autouse=True) def _dns_monde_reel(monkeypatch): monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip()) monkeypatch.setattr(mcp_client, "_sessions", {}) @pytest.fixture def admin_cookies(): with get_conn() as conn: conn.execute( "INSERT OR IGNORE INTO users (id, login, full_name, is_admin)" " VALUES (1, 'tester', 'Tester', 1)") conn.commit() session = SessionManager.create_session( {"id": 1, "login": "tester", "is_admin": 1}) return {"flowdeck_session": session} def _transcript_avec_resume(): with get_conn() as conn: pid = conn.execute( "INSERT INTO pages (workspace, title, content, content_format)" " VALUES ('test', 'Réunion hebdo', '', 'blocks')").lastrowid tid = conn.execute( "INSERT INTO meeting_transcripts (page_id, summary)" " VALUES (?, ?)", (pid, "## Décisions\n\n- Lancer le pilote d'archivage.\n" "- Revoir le pont MCP la semaine prochaine.")).lastrowid conn.commit() meetings._update(tid, generated_title="Hebdo produit", status="done") return tid def _creer_connecteur_obsigate(client): r = client.post("/api/agent/connectors", json={ "name": "ObsiGate", "kind": "obsigate", "url": "https://og.dracodev.net/mcp", "secret": "og_cle_pont_test", "auth": "bearer"}) assert r.status_code == 200, r.text return r.json()["id"] def _faux_rpc_obsigate(appels): async def fake_rpc(url, payload, headers=None): appels.append(payload.get("method")) method = payload.get("method") if method == "initialize": return {"result": {"protocolVersion": "2025-03-26", "serverInfo": {"name": "obsigate"}}} if method == "tools/call": params = payload.get("params") or {} appels.append((params.get("name"), params.get("arguments"))) return {"result": {"content": [ {"type": "text", "text": json.dumps({"draft_id": "b-42"})}]}} return {} return fake_rpc def test_demande_approbation_et_gardes(client, admin_cookies): tid = _transcript_avec_resume() r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate", cookies=admin_cookies) assert r.status_code == 201, r.text body = r.json() assert body["titre"] == "Hebdo produit" assert body["chemin"].startswith("Réunions/") assert body["chemin"].endswith("Hebdo produit.md") approval = body["approval"] assert approval["tool"] == "archiver_reunion_obsigate" assert approval["status"] == "pending" assert json.loads(approval["args_json"])["transcript_id"] == tid # Doublon refusé tant que la demande est en attente. r2 = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate", cookies=admin_cookies) assert r2.status_code == 400 def test_resume_vide_refuse(client, admin_cookies): with get_conn() as conn: pid = conn.execute( "INSERT INTO pages (workspace, title, content, content_format)" " VALUES ('test', 'Vide', '', 'blocks')").lastrowid tid = conn.execute( "INSERT INTO meeting_transcripts (page_id) VALUES (?)", (pid,)).lastrowid conn.commit() r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate", cookies=admin_cookies) assert r.status_code == 400 assert "Résumé vide" in r.text def test_approbation_execute_la_proposition(client, admin_cookies, monkeypatch): _creer_connecteur_obsigate(client) appels: list = [] monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels)) tid = _transcript_avec_resume() r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate", cookies=admin_cookies) aid = r.json()["approval"]["id"] d = client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=admin_cookies, json={"approve": True}) assert d.status_code == 200, d.text body = d.json() assert body["status"] == "approved" assert body["archive"]["status"] == "ok", body["archive"] assert "Réunions/" in body["archive"]["detail"] # propose_write_file a reçu le résumé, jamais un apply direct. calls = [a for a in appels if isinstance(a, tuple)] assert len(calls) == 1 and calls[0][0] == "propose_write_file" assert "Lancer le pilote" in calls[0][1]["content"] assert calls[0][1]["path"].endswith("Hebdo produit.md") # Distribution journalisée, canal obsigate. with get_conn() as conn: rows = conn.execute( "SELECT * FROM meeting_distributions WHERE transcript_id=?", (tid,)).fetchall() assert len(rows) == 1 assert rows[0]["channel"] == "obsigate" and rows[0]["status"] == "propose" def test_rejet_ne_envoie_rien(client, admin_cookies, monkeypatch): _creer_connecteur_obsigate(client) appels: list = [] monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels)) tid = _transcript_avec_resume() aid = meeting_archive.demander_approbation(tid, 1)["id"] d = client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=admin_cookies, json={"approve": False}) assert d.json()["archive"]["status"] == "rejete" assert not [a for a in appels if isinstance(a, tuple)] with get_conn() as conn: n = conn.execute( "SELECT COUNT(*) AS n FROM meeting_distributions" " WHERE transcript_id=?", (tid,)).fetchone()["n"] assert n == 0 def test_sans_connecteur_erreur_explicite(client, admin_cookies): tid = _transcript_avec_resume() aid = meeting_archive.demander_approbation(tid, 1)["id"] d = client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=admin_cookies, json={"approve": True}) archive = d.json()["archive"] assert archive["status"] == "erreur" assert "Connecteur ObsiGate non configuré" in archive["detail"] def test_construire_archive_contenu(client): tr = meetings.get_transcript(_transcript_avec_resume()) archive = meeting_archive.construire_archive(tr) assert archive["chemin"].startswith("Réunions/") assert "# Hebdo produit" in archive["contenu"] assert "Lancer le pilote" in archive["contenu"] assert "/pages/" in archive["contenu"] # lien profond vers la note def test_executer_async_direct(client, monkeypatch): appels: list = [] monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels)) tid = _transcript_avec_resume() approval = meeting_archive.demander_approbation(tid, 1) # Sans connecteur : erreur propre, pas d'exception. out = asyncio.run(meeting_archive.executer(approval)) assert out["status"] == "erreur"