"""FlowDeck — Agent tests (v4.10.0). Covers the DB schema, LLM client (offline mock), permission manager, tool registry (writes + undo snapshots + rollback), the ReAct engine (SSE events) and the /api/agent/* router (agents, conversations, run, audit, skills). """ import asyncio import json import os import tempfile import pytest from fastapi.testclient import TestClient @pytest.fixture def client(): db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) db_path = db_file.name db_file.close() os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["LLM_PROVIDER"] = "offline" # deterministic, no network from app.main import app from app.db import init_db, get_conn from app.password_utils import hash_password from app.config import settings # Ensure settings singleton uses OUR temp DB (not one set by another test file). settings.database_url = f"sqlite:///{db_path}" settings.llm_provider = "offline" settings.agent_max_iterations = 12 settings.agent_max_tokens_budget = 500_000 settings.agent_run_timeout_seconds = 30 init_db() with get_conn() as conn: conn.execute( "INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) " "VALUES ('admin', 'Admin', '', ?, 1)", (hash_password("test"),), ) conn.commit() yield TestClient(app) try: os.unlink(db_path) except FileNotFoundError: pass def _admin_id() -> int: from app.db import get_conn with get_conn() as conn: row = conn.execute("SELECT id FROM users WHERE login='admin'").fetchone() return row["id"] if row else 1 def _collect(events): return [e for e in events] async def _run_engine(engine, *args, **kwargs): return [e async for e in engine.run(*args, **kwargs)] # ── LLM client (offline mock) ── def test_llm_client_offline_mock(client): from app.services.llm_client import LLMClient llm = LLMClient(provider="offline") resp = asyncio.run(llm.complete( [{"role": "user", "content": "create collection Projets"}], tools=[], )) assert resp.tool_calls, "offline mock should detect a create-collection intent" assert resp.tool_calls[0]["name"] == "create_collection" assert resp.tool_calls[0]["arguments"]["name"] == "Projets" def test_llm_client_providers_listed(client): from app.services.llm_client import PROVIDERS for name in ("openai", "anthropic", "google", "deepseek", "qwencloud", "nvidia", "openrouter", "ollama", "offline"): assert name in PROVIDERS def test_llm_client_offline_no_credentials(client): from app.services.llm_client import LLMClient llm = LLMClient(provider="openai", api_key="") resp = asyncio.run(llm.complete( [{"role": "user", "content": "hello"}], tools=[], )) # No key → falls back to mock (a final text answer, no crash) assert resp.text != "" or resp.tool_calls # ── DB schema ── def test_agent_tables_exist(client): tables = {"agents", "agent_conversations", "agent_messages", "agent_actions", "agent_skills", "agent_triggers"} from app.db import get_conn with get_conn() as conn: rows = conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall() names = {r["name"] for r in rows} assert tables.issubset(names) # ── Tools ── def test_tool_create_collection_and_search(client): from app.services.tool_registry import ToolRegistry reg = ToolRegistry() res = asyncio.run(reg.execute("create_collection", {"name": "Projets"})) assert res.status == "success" cid = res.data["collection_id"] assert res.undo == {"action": "delete", "table": "collections", "id": cid} search = asyncio.run(reg.execute("search_workspace", {"query": "Projets"})) assert search.status == "success" assert any(c["id"] == cid for c in search.data["collections"]) def test_tool_create_page_with_properties(client): from app.services.tool_registry import ToolRegistry reg = ToolRegistry() coll = asyncio.run(reg.execute("create_collection", {"name": "Tasks"})) cid = coll.data["collection_id"] asyncio.run(reg.execute("add_property", {"collection_id": cid, "name": "Status", "prop_type": "select", "options": ["Todo", "Done"]})) page = asyncio.run(reg.execute("create_page", {"collection_id": cid, "title": "Task 1", "properties": {"Status": "Todo"}})) assert page.status == "success" assert page.target_type == "page" assert page.data["title"] == "Task 1" def test_tool_unknown_returns_error(client): from app.services.tool_registry import ToolRegistry res = asyncio.run(ToolRegistry().execute("nope", {})) assert res.status == "error" # ── Permissions ── def test_permission_manager_roles(client): from app.services.permission_manager import PermissionManager from app.db import get_conn with get_conn() as conn: owner = conn.execute("SELECT id FROM users WHERE login='admin'").fetchone() conn.execute("INSERT INTO users (login, is_admin) VALUES ('viewer_user', 0)") viewer = conn.execute("SELECT id FROM users WHERE login='viewer_user'").fetchone() conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('ws', ?)", (owner["id"],)) ws = conn.execute("SELECT id FROM workspaces WHERE name='ws'").fetchone() conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws["id"], viewer["id"], "viewer")) conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws["id"], owner["id"], "owner")) conn.commit() pm_owner = PermissionManager(owner["id"]) pm_viewer = PermissionManager(viewer["id"]) assert pm_owner.can_write(ws["id"]) is True assert pm_viewer.can_write(ws["id"]) is False from fastapi import HTTPException # viewer cannot write with pytest.raises(HTTPException): pm_viewer.assert_can("create_page", {}, ws["id"]) # owner can write pm_owner.assert_can("create_page", {}, ws["id"]) # destructive requires confirm mode with pytest.raises(HTTPException): pm_owner.assert_can("delete_page", {}, ws["id"], approval_mode="auto") pm_owner.assert_can("delete_page", {}, ws["id"], approval_mode="confirm") # ── Engine (ReAct + audit + rollback) ── def test_engine_run_creates_collection(client): from app.services.agent_engine import AgentEngine from app.db import get_conn engine = AgentEngine(_admin_id(), workspace_id=None) events = asyncio.run(_run_engine(engine, _make_conversation(client), "crée une collection Stats")) types = [e["type"] for e in events] assert "reasoning" in types assert "action" in types assert "final" in types actions = [e for e in events if e["type"] == "action"] assert any(a["tool"] == "create_collection" and a["status"] == "success" for a in actions) # Collection actually persisted with get_conn() as conn: row = conn.execute("SELECT id FROM collections WHERE name='Stats'").fetchone() assert row is not None def test_engine_audit_and_undo(client): from app.services.agent_engine import AgentEngine, undo_action from app.db import get_conn conv = _make_conversation(client) engine = AgentEngine(_admin_id()) asyncio.run(_run_engine(engine, conv, "créer une collection CRM")) with get_conn() as conn: coll = conn.execute("SELECT id FROM collections WHERE name='CRM'").fetchone() assert coll is not None action = conn.execute( "SELECT id FROM agent_actions WHERE tool_name='create_collection' AND target_id=?", (str(coll["id"]),), ).fetchone() assert action is not None # undo removes the collection and marks the action reverted undo_action(action["id"]) from app.db import get_conn as gc with gc() as conn: assert conn.execute("SELECT id FROM collections WHERE name='CRM'").fetchone() is None a = conn.execute("SELECT status FROM agent_actions WHERE id=?", (action["id"],)).fetchone() assert a["status"] == "reverted" def _make_conversation(client) -> int: resp = client.post("/api/agent/conversations", json={"title": "test"}) return resp.json()["id"] # ── Router: agents, conversations, run, skills ── def test_router_list_agents(client): resp = client.get("/api/agent") assert resp.status_code == 200 assert resp.json()["agents"] assert any(a["agent_type"] == "personal" for a in resp.json()["agents"]) def test_router_create_custom_agent(client): resp = client.post("/api/agent", json={"name": "Reporting", "agent_type": "custom", "system_instructions": "Produce weekly report"}) assert resp.status_code == 200 assert resp.json()["id"] # list now includes it agents = client.get("/api/agent").json()["agents"] assert any(a["name"] == "Reporting" for a in agents) def test_router_conversation_crud(client): c = client.post("/api/agent/conversations", json={"title": "Hello"}).json() cid = c["id"] detail = client.get(f"/api/agent/conversations/{cid}") assert detail.status_code == 200 assert detail.json()["conversation"]["id"] == cid assert client.delete(f"/api/agent/conversations/{cid}").status_code == 200 assert client.get(f"/api/agent/conversations/{cid}").status_code == 404 def test_router_run_sse_stream(client): cid = _make_conversation(client) resp = client.post(f"/api/agent/conversations/{cid}/run", json={"message": "create collection Roadmap"}) assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/event-stream") body = resp.text assert "data:" in body events = [_parse_sse(line) for line in body.strip().split("\n\n") if line.strip()] types = [e.get("type") for e in events if e] assert "final" in types def test_router_run_malformed(client): cid = _make_conversation(client) resp = client.post(f"/api/agent/conversations/{cid}/run", json={}) assert resp.status_code == 400 def test_router_actions_and_undo(client): cid = _make_conversation(client) client.post(f"/api/agent/conversations/{cid}/run", json={"message": "créer une collection Docs"}) actions = client.get(f"/api/agent/conversations/{cid}/actions").json()["actions"] assert actions, "run should have logged actions" target = actions[0] assert target["tool_name"] == "create_collection" # undo via endpoint r = client.post(f"/api/agent/actions/{target['id']}/undo") assert r.status_code == 200 assert r.json()["status"] == "reverted" def test_router_skills(client): s = client.post("/api/agent/skills", json={"name": "Préparation de sprint", "prompt_template": "Analyse les issues et crée un sprint", "allowed_tools": ["read_gitea_issues", "create_collection"]}) assert s.status_code == 200 sid = s.json()["id"] skills = client.get("/api/agent/skills").json()["skills"] assert any(k["name"] == "Préparation de sprint" for k in skills) # apply → creates a conversation ready to run apply = client.post(f"/api/agent/skills/{sid}/apply") assert apply.status_code == 200 assert apply.json()["conversation_id"] def test_router_tools_and_providers(client): tools = client.get("/api/agent/tools").json()["tools"] assert len(tools) >= 10 names = {t["name"] for t in tools} for expected in ("search_workspace", "create_collection", "create_page", "update_page", "add_property", "create_view", "read_page"): assert expected in names prov = client.get("/api/agent/providers").json() assert prov["provider"] == "offline" assert prov["available"] is False assert "providers" in prov def test_router_providers_enriched(client): prov = client.get("/api/agent/providers").json() assert "providers" in prov ids = [p["id"] for p in prov["providers"]] for expected in ("openai", "anthropic", "google", "ollama", "offline"): assert expected in ids openai = next(p for p in prov["providers"] if p["id"] == "openai") assert openai["default_model"] == "gpt-4o" assert openai["models"] assert openai["requires_key"] is True # offline requires no key and exposes no models offline = next(p for p in prov["providers"] if p["id"] == "offline") assert offline["requires_key"] is False def test_router_patch_providers_updates_config(client): from app.auth.session import SessionManager from app.db import get_conn with get_conn() as conn: admin = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone() sess = SessionManager.create_session(dict(admin)) client.cookies.set("flowdeck_session", sess) r = client.patch("/api/agent/providers", json={"provider": "openai", "model": "gpt-4o-mini", "api_key": "sk-test", "api_base": ""}) assert r.status_code == 200 body = r.json() assert body["provider"] == "openai" assert body["model"] == "gpt-4o-mini" assert body["has_api_key"] is True # persisted in llm_config (single row id=1) with get_conn() as conn: row = conn.execute("SELECT * FROM llm_config WHERE id=1").fetchone() assert row is not None assert row["provider"] == "openai" assert row["model"] == "gpt-4o-mini" assert row["api_key"] == "sk-test" # GET /providers reflects the new config prov = client.get("/api/agent/providers").json() assert prov["provider"] == "openai" assert prov["model"] == "gpt-4o-mini" assert prov["has_api_key"] is True # a real provider with a key is marked available (no network call made) assert prov["available"] is True def test_router_patch_providers_requires_admin(client): from app.auth.session import SessionManager from app.db import get_conn with get_conn() as conn: conn.execute("INSERT INTO users (login, full_name, is_admin) VALUES ('user_basic', 'Basic', 0)") user = conn.execute("SELECT * FROM users WHERE login='user_basic'").fetchone() conn.commit() sess = SessionManager.create_session(dict(user)) client.cookies.set("flowdeck_session", sess) r = client.patch("/api/agent/providers", json={"provider": "openai"}) assert r.status_code == 403 def test_router_providers_test_offline(client): r = client.post("/api/agent/providers/test", json={"provider": "offline"}) assert r.status_code == 200 assert r.json()["ok"] is True def test_router_providers_test_missing_key(client): # openai without a key must report failure (no mock fallback for the ping) r = client.post("/api/agent/providers/test", json={"provider": "openai", "api_key": ""}) assert r.status_code == 200 assert r.json()["ok"] is False assert "clé" in r.json()["error"].lower() def test_router_run_persists_provider_model(client): cid = _make_conversation(client) resp = client.post(f"/api/agent/conversations/{cid}/run", json={"message": "crée une collection Bilan", "provider": "openai", "model": "gpt-4o-mini"}) assert resp.status_code == 200 detail = client.get(f"/api/agent/conversations/{cid}").json() assert detail["conversation"]["provider"] == "openai" assert detail["conversation"]["model"] == "gpt-4o-mini" def _parse_sse(line: str) -> dict: for part in line.split("\n"): if part.startswith("data:"): try: return json.loads(part[len("data:"):].strip()) except json.JSONDecodeError: return {} return {}