Files
flowdeck/tests/test_fusion_tiktak.py
T
Spark 6a75b189ec
FlowDeck CI / lint (push) Failing after 2m9s
FlowDeck CI / test (push) Failing after 29m1s
FlowDeck CI / docker (push) Skipped
feat: Fusion ACC-2.1 — preset connecteur TikTak (MCP) et outils agent mcp_tiktak_* (#19)
2026-10-20 23:23:26 -04:00

137 lines
5.7 KiB
Python

"""Fusion ACC-2.1 — connecteur MCP Flowdeck → TikTak (première paire).
Le preset « tiktak » crée un connecteur MCP préconfiguré (URL du
endpoint /mcp de TikTak, auth bearer = clé `tck_` chiffrée). Le probe
parle le dialecte JSON-RPC réel de TikTak (initialize →
protocolVersion 2025-03-26, tools/list avec ses trois outils en
lecture, tools/call → content[0].text en JSON) ; les outils sont
cachés puis exposés à l'agent comme `mcp_tiktak_*`.
"""
import asyncio
import json
import pytest
from app.db import get_conn
from app.services import connectors, mcp_client
from app.services.importers import url_fetch
from app.services.tool_registry import ToolRegistry
@pytest.fixture(autouse=True)
def _dns_monde_reel(monkeypatch):
"""L'environnement de test local résout les hôtes publics vers
198.18.0.0/15 (plage « reserved ») : la garde SSRF les refuse, alors
qu'en production/CI tiktak.dracodev.net résout vers Cloudflare.
On neutralise la résolution pour la création — le comportement du
produit n'est pas modifié, seuls les tests s'affranchissent du DNS
local (les tests v755/v757 échouent localement pour la même cause).
"""
monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip())
# Outils tels que TikTak les déclare (app/routers/sync.py, _OUTILS).
OUTILS_TIKTAK = [
{"name": "list_today_tasks",
"description": "Liste les tâches actives de l'utilisateur échéant aujourd'hui.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "search_tasks",
"description": "Recherche des tâches et des notes par mots.",
"inputSchema": {"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]}},
{"name": "get_task",
"description": "Lit une tâche par son identifiant.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
]
def _creer_tiktak(client, **kw):
body = {"name": "TikTak", "kind": "tiktak",
"url": "https://tiktak.dracodev.net/mcp",
"secret": "tck_cle_de_test", "auth": "bearer", **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
def _faux_rpc_tiktak(appels, entetes):
"""Rejoue le serveur MCP de TikTak (POST /mcp, JSON-RPC)."""
async def fake_rpc(url, payload, headers=None):
assert url == "https://tiktak.dracodev.net/mcp"
entetes.append(headers or {})
appels.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return {"result": {"protocolVersion": "2025-03-26",
"capabilities": {"tools": {}},
"serverInfo": {"name": "tiktak", "version": "0.51.0"}}}
if method == "tools/list":
return {"result": {"tools": OUTILS_TIKTAK}}
if method == "tools/call":
params = payload.get("params") or {}
if params.get("name") == "list_today_tasks":
res = [{"id": 42, "title": "Appeler le dentiste", "list": "Perso"}]
else:
res = {"id": 42, "title": "Appeler le dentiste"}
return {"result": {"content": [{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}}
return {}
return fake_rpc
def test_preset_tiktak_et_secret_chiffre(client):
row = _creer_tiktak(client)
assert row["kind"] == "tiktak" and row["auth"] == "bearer"
assert row["has_secret"] is True
assert "tck_cle_de_test" not in json.dumps(row) # jamais renvoyée
assert connectors.PRESETS["tiktak"]["url"] == "https://tiktak.dracodev.net/mcp"
hdrs = connectors._headers(row["id"])
assert hdrs["Authorization"] == "Bearer tck_cle_de_test" # ce qu'attend TikTak
def test_probe_tiktak_handshake_et_cache(client, monkeypatch):
row = _creer_tiktak(client)
appels, entetes = [], []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_tiktak(appels, entetes))
res = asyncio.run(connectors.probe(str(row["id"])))
assert res["status"] == "ok", res
assert "3 outil(s)" in res["detail"]
assert appels == ["initialize", "notifications/initialized", "tools/list"]
assert entetes[0]["Authorization"] == "Bearer tck_cle_de_test"
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(row["id"],)).fetchone()["tools_json"]
for nom in ("mcp_tiktak_list_today_tasks", "mcp_tiktak_search_tasks",
"mcp_tiktak_get_task"):
assert nom in tools
def test_outils_tiktak_exposes_a_l_agent(client, monkeypatch):
row = _creer_tiktak(client)
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_tiktak([], []))
asyncio.run(connectors.probe(str(row["id"])))
reg = ToolRegistry()
schema = {t["name"]: t for t in reg.schema()}
assert "mcp_tiktak_list_today_tasks" in schema
assert schema["mcp_tiktak_search_tasks"]["parameters"]["required"] == ["query"]
assert schema["mcp_tiktak_get_task"]["parameters"]["required"] == ["id"]
res = asyncio.run(reg.execute("mcp_tiktak_list_today_tasks", {}))
assert res.status == "success"
assert "Appeler le dentiste" in res.data["text"]
def test_tiktak_desactive_masque_ses_outils(client, monkeypatch):
row = _creer_tiktak(client)
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_tiktak([], []))
asyncio.run(connectors.probe(str(row["id"])))
client.patch(f"/api/agent/connectors/{row['id']}", json={"enabled": False})
noms = {t["name"] for t in ToolRegistry().schema()}
assert "mcp_tiktak_list_today_tasks" not in noms