diff --git a/CHANGELOG.md b/CHANGELOG.md index b106e80..862b4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog - FlowDeck +## v7.80.0 (2026-10-22) — Émission d'événements vers le journal du service d'agents (Lot 2) + +- **Nouveau module `app/services/agent_evenements.py`** : Flowdeck + émet ses mutations significatives vers le journal d'activité du + service d'agents (`POST /api/v1/evenements`, contrat ACC-0.2) : + enveloppe {id, type, occurred_at, actor, domain, source_app, + object{ref, label, url}} — une référence et un libellé, jamais le + contenu — signée **HMAC-SHA256** (en-tête `X-Draco-Signature`) + avec la clé propre à Flowdeck. Envoi en fil d'arrière-plan, délai + maximal 2 s, erreurs avalées : **inactif tant que + `AGENT_EVENEMENTS_CLE` n'est pas posée** dans l'environnement, et + une panne du service ne casse ni ne ralentit jamais le métier. +- **Événements émis** : `flowdeck.page.created` / + `flowdeck.page.updated` / `flowdeck.page.deleted` et + `flowdeck.collection.created`, branchés sur les surfaces réelles + de mutation : API de session (pages du board, pages et + collections des bases), API publique v2 (acteur « user ») et + outils MCP `create_page` / `update_page` / `create_collection` + (acteur « agent:mcp », dans le périmètre du jeton). Références + `page:` / `collection:`, liens profonds vers la page + (`/board/pages/`) ou la vue de sa collection. +- **Aucun événement meeting** : la décision A/B sur le bloc + meeting reste ouverte, le sujet est explicitement exclu. + ## v7.79.0 (2026-10-22) — Panneau Spark v2 (Lot 1) - **Panneau Spark** (`draco-panneau.js/css`, vendangés dans les 4 diff --git a/VERSION b/VERSION index 33654fc..2d1c706 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.79.0 +7.80.0 diff --git a/app/routers/api_v2/collections.py b/app/routers/api_v2/collections.py index 6fb815c..786705e 100644 --- a/app/routers/api_v2/collections.py +++ b/app/routers/api_v2/collections.py @@ -102,6 +102,9 @@ def create_collection_v2(request: Request, authorization: str | None = Header(de conn.commit() row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone() audit_log(user, "collection.create", "collection", cid, name, request) + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.collection.created", f"collection:{cid}", name, + url=url_collection(cid), actor="user") data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)} key = (request.headers.get("Idempotency-Key") or "").strip() if key: @@ -349,6 +352,9 @@ def create_collection_page_v2(collection_id: int, request: Request, authorizatio conn.commit() row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone() audit_log(user, "page.create", "collection_page", pid, title, request) + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.page.created", f"page:{pid}", title, + url=url_collection(collection_id), actor="user") try: run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})) except Exception: @@ -426,6 +432,9 @@ def patch_page_v2(page_id: int, request: Request, authorization: str | None = He conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id)) conn.commit() audit_log(user, "page.update", "collection_page", page_id, "", request) + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.page.updated", f"page:{page_id}", title, + url=url_collection(row["collection_id"]), actor="user") try: run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})) except Exception: @@ -444,6 +453,9 @@ def delete_page_v2(page_id: int, request: Request, authorization: str | None = H conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) conn.commit() audit_log(user, "page.delete", "collection_page", page_id, "", request) + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"], + url=url_collection(row["collection_id"]), actor="user") try: run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})) except Exception: diff --git a/app/routers/board/page_ops.py b/app/routers/board/page_ops.py index 3c46ad0..5f9a5a0 100644 --- a/app/routers/board/page_ops.py +++ b/app/routers/board/page_ops.py @@ -86,6 +86,9 @@ def delete_page(request: Request, page_id: int): conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,)) conn.commit() run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})) + from app.services.agent_evenements import emettre, url_page + emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"] or "", + url=url_page(page_id), actor="user") return {"status": "ok", "deleted": page_id, "title": row["title"]} diff --git a/app/routers/board/pages.py b/app/routers/board/pages.py index eb36be8..ea54ed9 100644 --- a/app/routers/board/pages.py +++ b/app/routers/board/pages.py @@ -55,6 +55,9 @@ def create_page(request: Request, title: str = Query(default=""), page_id = cur.lastrowid run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id})) + from app.services.agent_evenements import emettre, url_page + emettre("flowdeck.page.created", f"page:{page_id}", page_title, + url=url_page(page_id), actor="user") return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id} except Exception as e: logger.error("create_page failed: %s", e) @@ -109,9 +112,14 @@ def update_page(request: Request, page_id: int, title: str = Query(default=""), if content_format: conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id)) conn.commit() + trow = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone() + page_label = (trow["title"] if trow else title) or "" run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title, "content_format": content_format or "markdown", "actor_id": user.get("id")})) + from app.services.agent_evenements import emettre, url_page + emettre("flowdeck.page.updated", f"page:{page_id}", page_label, + url=url_page(page_id), actor="user") return {"status": "ok"} diff --git a/app/routers/collections/crud.py b/app/routers/collections/crud.py index ccbf833..f6f99ca 100644 --- a/app/routers/collections/crud.py +++ b/app/routers/collections/crud.py @@ -118,6 +118,9 @@ def create_collection_api(request: Request, body: dict = Body(default={})): conn.commit() run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})) + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.collection.created", f"collection:{collection_id}", name, + url=url_collection(collection_id), actor="user") return {"id": collection_id, "name": name, "status": "created"} diff --git a/app/routers/collections/data_api.py b/app/routers/collections/data_api.py index 93b1b2c..6ffcfbb 100644 --- a/app/routers/collections/data_api.py +++ b/app/routers/collections/data_api.py @@ -119,4 +119,7 @@ def create_page_api(request: Request, collection_id: int, body: dict = Body(defa "collection_id": collection_id, "title": title, })) + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.page.created", f"page:{page_id}", title, + url=url_collection(collection_id), actor="user") return {"id": page_id, "title": title, "status": "created"} diff --git a/app/routers/collections/pages.py b/app/routers/collections/pages.py index e8d8a66..b0122be 100644 --- a/app/routers/collections/pages.py +++ b/app/routers/collections/pages.py @@ -149,6 +149,9 @@ def update_page_api(request: Request, page_id: int, body: dict = Body(default={} user = _current_user(request) notify_assignment(existing["collection_id"], page_id, title, stored, props, user.get("id")) + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.page.updated", f"page:{page_id}", title, + url=url_collection(existing["collection_id"]), actor="user") return {"id": page_id, "status": "updated"} @@ -178,6 +181,9 @@ def delete_page_api(request: Request, page_id: int): "page_id": page_id, "collection_id": existing["collection_id"], })) + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.page.deleted", f"page:{page_id}", existing["title"], + url=url_collection(existing["collection_id"]), actor="user") return {"id": page_id, "status": "deleted"} diff --git a/app/routers/mcp_server.py b/app/routers/mcp_server.py index b460486..208c84d 100644 --- a/app/routers/mcp_server.py +++ b/app/routers/mcp_server.py @@ -237,6 +237,9 @@ def _appel_outil(nom: str, args: dict, user_id: int): conn.commit() page = conn.execute("SELECT * FROM collection_pages WHERE id = ?", (cur.lastrowid,)).fetchone() + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.page.created", f"page:{page['id']}", title, + url=url_collection(collection_id), actor="agent:mcp") return _page_resume(page) if nom == "update_page": try: @@ -265,6 +268,9 @@ def _appel_outil(nom: str, args: dict, user_id: int): conn.commit() updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?", (page_id,)).fetchone() + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.page.updated", f"page:{page_id}", title, + url=url_collection(page["collection_id"]), actor="agent:mcp") return _page_resume(updated) if nom == "create_collection": name = str(args.get("name") or "").strip() @@ -287,6 +293,9 @@ def _appel_outil(nom: str, args: dict, user_id: int): conn.commit() coll = conn.execute("SELECT * FROM collections WHERE id = ?", (new_id,)).fetchone() + from app.services.agent_evenements import emettre, url_collection + emettre("flowdeck.collection.created", f"collection:{new_id}", name, + url=url_collection(new_id), actor="agent:mcp") return {"id": coll["id"], "name": coll["name"], "description": coll["description"], "icon": coll["icon"]} return {"erreur": f"outil inconnu : {nom}"} diff --git a/app/services/agent_evenements.py b/app/services/agent_evenements.py new file mode 100644 index 0000000..2b83276 --- /dev/null +++ b/app/services/agent_evenements.py @@ -0,0 +1,120 @@ +"""Émission d'événements vers le journal du service d'agents +(Fusion, Lot 2 — contrat ACC-0.2). + +Flowdeck émet ses mutations significatives (pages et collections) +vers `POST /api/v1/evenements` du service d'agents, qui les expose +dans le « Journal d'aujourd'hui » de la console. L'enveloppe ne +contient qu'une référence, un libellé et un lien — jamais le +contenu de l'objet : {id, type, occurred_at, actor, domain, +source_app, object{ref, label, url}}. + +Authentification : signature HMAC-SHA256 du corps brut, en-tête +`X-Draco-Signature`, avec la clé propre à Flowdeck +(`AGENT_EVENEMENTS_CLE`, jamais en dur). Sans clé, le module est +INACTIF : aucun appel, aucun bruit. + +Discipline « au mieux » (même esprit que le journal du service) : +l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s, +et toute erreur est avalée avec une ligne de journal — une panne +du récepteur ne doit jamais casser ni ralentir le métier. + +Aucun événement « meeting » n'est émis ici : la décision A/B de +Bruno sur le bloc meeting de Flowdeck est toujours ouverte. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import threading +import uuid +from datetime import datetime, timezone + +import httpx + +log = logging.getLogger("flowdeck.evenements") + +SOURCE_APP = "flowdeck" +URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements" +DELAI_S = 2.0 + + +def _cle() -> str: + return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip() + + +def _url() -> str: + return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT + + +def actif() -> bool: + """L'émission n'existe que si la clé de Flowdeck est configurée.""" + return bool(_cle()) + + +def url_page(page_id: int) -> str: + """Lien profond d'une page d'espace de travail (vue HTML du board).""" + return f"/board/pages/{page_id}" + + +def url_collection(collection_id: int) -> str: + """Lien profond d'une collection (sa vue tableau). Une page de + collection n'a pas d'URL autonome : elle s'ouvre dans cette vue.""" + return f"/db/{collection_id}/view/table" + + +def construire_enveloppe(type_: str, objet_ref: str, label: str, + url: str = "/", domain: str = "pro", + actor: str | None = None) -> tuple[dict, bytes]: + """L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est + signé puis envoyé — la signature porte sur ces octets-là).""" + env = { + "id": uuid.uuid4().hex, + "type": type_, + "occurred_at": datetime.now(timezone.utc).isoformat(), + "actor": actor or "user", + "domain": domain or "pro", + "source_app": SOURCE_APP, + "object": {"ref": objet_ref, "label": label or "", "url": url or "/"}, + } + corps = json.dumps(env, ensure_ascii=False).encode("utf-8") + return env, corps + + +def _signer(corps: bytes) -> str: + return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest() + + +def _envoyer(corps: bytes) -> None: + """Le POST signé, dans le fil d'arrière-plan. Avale tout : le + journal distant est un confort, jamais une dépendance.""" + try: + with httpx.Client(timeout=DELAI_S, trust_env=False) as client: + client.post( + _url(), content=corps, + headers={"Content-Type": "application/json", + "X-Draco-Signature": _signer(corps)}, + ) + except Exception as exc: # panne, délai, DNS… : on log et on passe + log.warning("événement non livré au journal du service (%s)", exc) + + +def emettre(type_: str, objet_ref: str, label: str, url: str = "/", + domain: str = "pro", actor: str | None = None) -> bool: + """Émet un événement en tâche de fond. Retourne True si l'envoi + a été déclenché, False si le module est inactif (pas de clé) ou + si l'enveloppe n'a pas pu être construite. Ne lève jamais.""" + if not actif(): + return False + try: + _env, corps = construire_enveloppe(type_, objet_ref, label, url, + domain, actor) + except Exception: + log.warning("événement %s non construit", type_, exc_info=True) + return False + threading.Thread(target=_envoyer, args=(corps,), daemon=True, + name="flowdeck-evenements").start() + return True diff --git a/tests/test_evenements_journal.py b/tests/test_evenements_journal.py new file mode 100644 index 0000000..12fa84c --- /dev/null +++ b/tests/test_evenements_journal.py @@ -0,0 +1,282 @@ +"""Émission d'événements vers le journal du service d'agents +(Fusion, Lot 2 — contrat ACC-0.2). + +Un faux récepteur local capture les POST signés HMAC et les vérifie +comme le fait le service (journal.verifier_signature) ; on prouve +aussi l'inactivité sans clé et la non-régression métier quand le +récepteur est en panne. Surfaces couvertes : API de session (board +et collections), API publique v2 et MCP (acteur « agent:mcp »). +""" + +import hashlib +import hmac +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from app.db import get_conn + +CLE = "cle-flowdeck-de-test" + + +class _Recepteur(BaseHTTPRequestHandler): + recus = [] + + def do_POST(self): + n = int(self.headers.get("Content-Length") or 0) + corps = self.rfile.read(n) + type(self).recus.append( + {"headers": {k.lower(): v for k, v in self.headers.items()}, + "corps": corps}) + self.send_response(201) + self.end_headers() + + def log_message(self, *a): + pass + + +def _demarrer(monkeypatch): + _Recepteur.recus = [] + serveur = HTTPServer(("127.0.0.1", 0), _Recepteur) + port = serveur.server_address[1] + threading.Thread(target=serveur.serve_forever, daemon=True).start() + monkeypatch.setenv("AGENT_EVENEMENTS_URL", + f"http://127.0.0.1:{port}/api/v1/evenements") + monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE) + return serveur + + +def _attendre(nombre, delai=6.0): + """Attend `nombre` événements ; vérifie la signature HMAC de + chacun (comme le service) et retourne la liste des enveloppes.""" + fin = time.time() + delai + while time.time() < fin and len(_Recepteur.recus) < nombre: + time.sleep(0.05) + assert len(_Recepteur.recus) >= nombre, \ + f"{len(_Recepteur.recus)} événement(s) reçu(s), {nombre} attendus" + envs = [] + for recu in _Recepteur.recus: + sig = recu["headers"].get("x-draco-signature", "") + attendu = hmac.new(CLE.encode(), recu["corps"], + hashlib.sha256).hexdigest() + assert hmac.compare_digest(sig.lower(), attendu), \ + "signature HMAC invalide" + env = json.loads(recu["corps"].decode("utf-8")) + assert env["source_app"] == "flowdeck" + assert env["id"] and env["occurred_at"] + envs.append(env) + return envs + + +def _par_type(envs, type_): + return [e for e in envs if e["type"] == type_] + + +def test_enveloppe_et_signature(): + from app.services import agent_evenements + + env, corps = agent_evenements.construire_enveloppe( + "flowdeck.page.created", "page:42", "Page de test", + url="/board/pages/42", actor="user") + assert env["source_app"] == "flowdeck" + assert env["domain"] == "pro" + assert env["actor"] == "user" + assert env["object"] == {"ref": "page:42", "label": "Page de test", + "url": "/board/pages/42"} + assert json.loads(corps.decode("utf-8")) == env + sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest() + assert len(sig) == 64 + + +def test_cycle_de_vie_api_session(client, monkeypatch): + serveur = _demarrer(monkeypatch) + try: + # Page d'espace de travail (board) : création, màj, suppression. + r = client.post("/board/api/pages", params={"title": "Page journal"}) + assert r.status_code == 200, r.text + page_id = r.json()["id"] + r = client.put(f"/board/api/pages/{page_id}", + params={"title": "Page journal (éditée)"}) + assert r.status_code == 200, r.text + r = client.delete(f"/board/api/pages/{page_id}") + assert r.status_code == 200, r.text + + # Collection puis sa page : création, màj, suppression. + r = client.post("/db/api", json={"name": "Collection journal"}) + assert r.status_code == 200, r.text + cid = r.json()["id"] + r = client.post(f"/db/{cid}/pages/api", + json={"title": "Ligne journal"}) + assert r.status_code == 200, r.text + ligne_id = r.json()["id"] + r = client.put(f"/db/pages/{ligne_id}/api", + json={"title": "Ligne journal (éditée)"}) + assert r.status_code == 200, r.text + r = client.delete(f"/db/pages/{ligne_id}/api") + assert r.status_code == 200, r.text + + envs = _attendre(7) + + crees = _par_type(envs, "flowdeck.page.created") + assert len(crees) == 2 + urls = {e["object"]["url"] for e in crees} + assert f"/board/pages/{page_id}" in urls + assert f"/db/{cid}/view/table" in urls + board = next(e for e in crees + if e["object"]["url"] == f"/board/pages/{page_id}") + assert board["object"]["ref"] == f"page:{page_id}" + assert board["object"]["label"] == "Page journal" + assert board["actor"] == "user" + + maj = _par_type(envs, "flowdeck.page.updated") + assert len(maj) == 2 + labels = {e["object"]["label"] for e in maj} + assert labels == {"Page journal (éditée)", "Ligne journal (éditée)"} + + suppr = _par_type(envs, "flowdeck.page.deleted") + assert len(suppr) == 2 + refs = {e["object"]["ref"] for e in suppr} + assert refs == {f"page:{page_id}", f"page:{ligne_id}"} + + coll = _par_type(envs, "flowdeck.collection.created") + assert len(coll) == 1 + assert coll[0]["object"] == { + "ref": f"collection:{cid}", "label": "Collection journal", + "url": f"/db/{cid}/view/table"} + assert coll[0]["actor"] == "user" + finally: + serveur.shutdown() + + +def test_api_publique_v2(client, monkeypatch): + serveur = _demarrer(monkeypatch) + try: + r = client.post("/auth/register", json={ + "email": "journal@test.dev", "password": "secret123", + "name": "journal"}) + assert r.status_code == 200, r.text + tok = client.post("/api/v1/token").json()["token"] + h1 = {"Authorization": f"Bearer {tok}"} + r = client.post("/api/v2/tokens", + json={"name": "ci", "scopes": "read,write"}, + headers=h1) + assert r.status_code == 200, r.text + h = {"Authorization": f"Bearer {r.json()['token']}"} + + r = client.post("/api/v2/collections", + json={"name": "Collection v2"}, headers=h) + assert r.status_code == 201, r.text + cid = r.json()["id"] + r = client.post(f"/api/v2/collections/{cid}/pages", + json={"title": "Page v2"}, headers=h) + assert r.status_code == 201, r.text + pid = r.json()["id"] + r = client.patch(f"/api/v2/pages/{pid}", + json={"title": "Page v2 (éditée)"}, headers=h) + assert r.status_code == 200, r.text + r = client.delete(f"/api/v2/pages/{pid}", headers=h) + assert r.status_code == 200, r.text + + envs = _attendre(4) + types = [e["type"] for e in envs] + assert types.count("flowdeck.collection.created") == 1 + assert types.count("flowdeck.page.created") == 1 + assert types.count("flowdeck.page.updated") == 1 + assert types.count("flowdeck.page.deleted") == 1 + assert all(e["actor"] == "user" for e in envs) + coll = _par_type(envs, "flowdeck.collection.created")[0] + assert coll["object"]["ref"] == f"collection:{cid}" + page = _par_type(envs, "flowdeck.page.created")[0] + assert page["object"]["ref"] == f"page:{pid}" + assert page["object"]["url"] == f"/db/{cid}/view/table" + finally: + serveur.shutdown() + + +@pytest.fixture() +def jeton_mcp(client): + brut = "fd_test_token_journal" + with get_conn() as conn: + conn.execute( + "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) " + "VALUES (1, 'service-agents', ?, ?, 0)", + (hashlib.sha256(brut.encode()).hexdigest(), brut[:12])) + conn.commit() + return brut + + +def _appel_mcp(client, jeton, rid, nom, args=None): + r = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": rid, "method": "tools/call", + "params": {"name": nom, "arguments": args or {}}}, + headers={"Authorization": f"Bearer {jeton}"}) + assert r.status_code == 200, r.text + return json.loads(r.json()["result"]["content"][0]["text"]) + + +def test_mcp_acteur_agent(client, monkeypatch, jeton_mcp): + serveur = _demarrer(monkeypatch) + try: + coll = _appel_mcp(client, jeton_mcp, 1, "create_collection", + {"name": "Collection MCP"}) + cid = coll["id"] + page = _appel_mcp(client, jeton_mcp, 2, "create_page", + {"collection_id": cid, "title": "Page MCP"}) + pid = page["id"] + _appel_mcp(client, jeton_mcp, 3, "update_page", + {"id": pid, "title": "Page MCP (éditée)"}) + + envs = _attendre(3) + assert all(e["actor"] == "agent:mcp" for e in envs) + coll_env = _par_type(envs, "flowdeck.collection.created") + assert len(coll_env) == 1 + assert coll_env[0]["object"] == { + "ref": f"collection:{cid}", "label": "Collection MCP", + "url": f"/db/{cid}/view/table"} + creee = _par_type(envs, "flowdeck.page.created") + assert len(creee) == 1 + assert creee[0]["object"]["ref"] == f"page:{pid}" + assert creee[0]["object"]["label"] == "Page MCP" + maj = _par_type(envs, "flowdeck.page.updated") + assert len(maj) == 1 + assert maj[0]["object"]["label"] == "Page MCP (éditée)" + finally: + serveur.shutdown() + + +def test_inactif_sans_cle(client, monkeypatch): + serveur = _demarrer(monkeypatch) + monkeypatch.delenv("AGENT_EVENEMENTS_CLE") + try: + from app.services import agent_evenements + + assert agent_evenements.actif() is False + assert agent_evenements.emettre( + "flowdeck.page.created", "page:1", "x") is False + r = client.post("/board/api/pages", params={"title": "Sans journal"}) + assert r.status_code == 200, r.text + r = client.post("/db/api", json={"name": "Collection sans journal"}) + assert r.status_code == 200, r.text + time.sleep(0.6) + assert _Recepteur.recus == [] + finally: + serveur.shutdown() + + +def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch): + # Port fermé : connexion refusée dans le fil d'arrière-plan. + monkeypatch.setenv("AGENT_EVENEMENTS_URL", + "http://127.0.0.1:59998/api/v1/evenements") + monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE) + r = client.post("/board/api/pages", params={"title": "Malgré la panne"}) + assert r.status_code == 200, r.text + page_id = r.json()["id"] + r = client.get(f"/board/api/pages/{page_id}") + assert r.status_code == 200, r.text + assert r.json()["title"] == "Malgré la panne" + r = client.post("/db/api", json={"name": "Collection malgré la panne"}) + assert r.status_code == 200, r.text