"""Tests for workflows router endpoints.""" import json from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import aiohttp import pytest def test_workflows_requires_auth(test_client): """GET /api/workflows without auth → 401.""" resp = test_client.get("/api/workflows") assert resp.status_code == 401 def test_workflows_authenticated(test_client): """GET /api/workflows with auth → 200, returns workflow catalog.""" resp = test_client.get("/api/workflows", headers=test_client.auth_headers) assert resp.status_code == 200 data = resp.json() assert "workflows" in data assert "categories" in data assert isinstance(data["workflows"], list) assert isinstance(data["categories"], dict) def test_workflow_enable_requires_auth(test_client): """POST /api/workflows/{id}/enable without auth → 401.""" resp = test_client.post("/api/workflows/test-workflow/enable") assert resp.status_code == 401 def test_workflow_disable_requires_auth(test_client): """DELETE /api/workflows/{id} without auth → 401.""" resp = test_client.delete("/api/workflows/test-workflow") assert resp.status_code == 401 def test_workflow_executions_requires_auth(test_client): """GET /api/workflows/{id}/executions without auth → 401.""" resp = test_client.get("/api/workflows/test-workflow/executions") assert resp.status_code == 401 def test_workflow_enable_authenticated(test_client): """POST /api/workflows/{id}/enable with auth → 404 when workflow not in catalog.""" resp = test_client.post( "/api/workflows/nonexistent-workflow/enable", headers=test_client.auth_headers ) assert resp.status_code == 404 def test_workflow_disable_authenticated(test_client): """DELETE /api/workflows/{id} with auth → 404 when workflow not in catalog.""" resp = test_client.delete( "/api/workflows/nonexistent-workflow", headers=test_client.auth_headers ) assert resp.status_code == 404 def test_workflow_executions_authenticated(test_client): """GET /api/workflows/{id}/executions with auth → 404 when workflow not in catalog.""" resp = test_client.get( "/api/workflows/nonexistent-workflow/executions", headers=test_client.auth_headers ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # load_workflow_catalog() unit tests # --------------------------------------------------------------------------- def test_load_workflow_catalog_missing_file(monkeypatch): """load_workflow_catalog returns DEFAULT_WORKFLOW_CATALOG when file does not exist.""" import routers.workflows as wf_mod fake_path = Path("/tmp/nonexistent-catalog-dir/catalog.json") monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", fake_path) result = wf_mod.load_workflow_catalog() assert result == wf_mod.DEFAULT_WORKFLOW_CATALOG def test_load_workflow_catalog_invalid_json(tmp_path, monkeypatch): """load_workflow_catalog returns DEFAULT_WORKFLOW_CATALOG for invalid JSON.""" import routers.workflows as wf_mod catalog_file = tmp_path / "catalog.json" catalog_file.write_text("{not valid json!!!") monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) result = wf_mod.load_workflow_catalog() assert result == wf_mod.DEFAULT_WORKFLOW_CATALOG def test_load_workflow_catalog_non_dict_json(tmp_path, monkeypatch): """load_workflow_catalog returns DEFAULT_WORKFLOW_CATALOG when root is not a dict.""" import routers.workflows as wf_mod catalog_file = tmp_path / "catalog.json" catalog_file.write_text('["this", "is", "a", "list"]') monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) result = wf_mod.load_workflow_catalog() assert result == wf_mod.DEFAULT_WORKFLOW_CATALOG def test_load_workflow_catalog_valid(tmp_path, monkeypatch): """load_workflow_catalog returns parsed catalog when file is valid.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "wf-1", "name": "Test Workflow", "description": "A test", "file": "test.json"} ], "categories": {"automation": {"name": "Automation", "icon": "Cog"}} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) result = wf_mod.load_workflow_catalog() assert len(result["workflows"]) == 1 assert result["workflows"][0]["id"] == "wf-1" assert "automation" in result["categories"] def test_load_workflow_catalog_invalid_inner_types(tmp_path, monkeypatch): """load_workflow_catalog normalises non-list workflows and non-dict categories.""" import routers.workflows as wf_mod catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps({"workflows": "not-a-list", "categories": "not-a-dict"})) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) result = wf_mod.load_workflow_catalog() assert result["workflows"] == [] assert result["categories"] == {} # --------------------------------------------------------------------------- # get_n8n_workflows() unit tests # --------------------------------------------------------------------------- def test_get_n8n_workflows_success(test_client, monkeypatch): """get_n8n_workflows returns workflow list on 200 response.""" import routers.workflows as wf_mod n8n_data = {"data": [{"id": "1", "name": "My Workflow", "active": True}]} resp_mock = AsyncMock() resp_mock.status = 200 resp_mock.json = AsyncMock(return_value=n8n_data) ctx = AsyncMock() ctx.__aenter__ = AsyncMock(return_value=resp_mock) ctx.__aexit__ = AsyncMock(return_value=False) session_mock = AsyncMock() session_mock.get = MagicMock(return_value=ctx) session_mock.__aenter__ = AsyncMock(return_value=session_mock) session_mock.__aexit__ = AsyncMock(return_value=False) with patch("routers.workflows.aiohttp.ClientSession", return_value=session_mock): import asyncio result = asyncio.run(wf_mod.get_n8n_workflows()) assert len(result) == 1 assert result[0]["name"] == "My Workflow" def test_get_n8n_workflows_failure(test_client, monkeypatch): """get_n8n_workflows returns empty list on connection error.""" import routers.workflows as wf_mod session_mock = AsyncMock() session_mock.get = MagicMock(side_effect=aiohttp.ClientError("connection refused")) session_mock.__aenter__ = AsyncMock(return_value=session_mock) session_mock.__aexit__ = AsyncMock(return_value=False) with patch("routers.workflows.aiohttp.ClientSession", return_value=session_mock): import asyncio result = asyncio.run(wf_mod.get_n8n_workflows()) assert result == [] # --------------------------------------------------------------------------- # check_workflow_dependencies() unit tests # --------------------------------------------------------------------------- def test_check_workflow_dependencies_all_healthy(test_client, monkeypatch): """check_workflow_dependencies returns True for healthy deps.""" import routers.workflows as wf_mod from models import ServiceStatus async def mock_health(sid, cfg): return ServiceStatus( id=sid, name=cfg["name"], port=cfg["port"], external_port=cfg.get("external_port", cfg["port"]), status="healthy", ) monkeypatch.setattr("helpers.check_service_health", mock_health) import asyncio result = asyncio.run( wf_mod.check_workflow_dependencies(["llama-server"]) ) # "llama-server" should be checked directly (no alias mapping) # Result depends on whether llama-server is in SERVICES assert isinstance(result, dict) def test_check_workflow_dependencies_with_alias(test_client, monkeypatch): """check_workflow_dependencies resolves 'ollama' alias to 'llama-server'.""" import routers.workflows as wf_mod from models import ServiceStatus healthy_status = ServiceStatus( id="llama-server", name="LLM Server", port=8080, external_port=8080, status="healthy", ) mock_fn = AsyncMock(return_value=healthy_status) monkeypatch.setattr("helpers.check_service_health", mock_fn) import asyncio result = asyncio.run( wf_mod.check_workflow_dependencies(["ollama"]) ) assert result["ollama"] is True def test_check_workflow_dependencies_unhealthy(test_client, monkeypatch): """check_workflow_dependencies returns False for unhealthy deps.""" import routers.workflows as wf_mod from models import ServiceStatus # Ensure "llama-server" is in SERVICES so the code path actually calls check_service_health monkeypatch.setitem(wf_mod.SERVICES, "llama-server", {"name": "LLM Server", "port": 8080}) unhealthy_status = ServiceStatus( id="llama-server", name="LLM Server", port=8080, external_port=8080, status="down", ) mock_fn = AsyncMock(return_value=unhealthy_status) monkeypatch.setattr("helpers.check_service_health", mock_fn) import asyncio result = asyncio.run( wf_mod.check_workflow_dependencies(["ollama"]) ) assert result["ollama"] is False def test_check_workflow_dependencies_unknown_dep(test_client, monkeypatch): """check_workflow_dependencies returns True for deps not in SERVICES.""" import routers.workflows as wf_mod import asyncio result = asyncio.run( wf_mod.check_workflow_dependencies(["totally-unknown-service-xyz"]) ) assert result["totally-unknown-service-xyz"] is True def test_check_workflow_dependencies_uses_cache(test_client, monkeypatch): """check_workflow_dependencies reuses health_cache and skips duplicate checks.""" import routers.workflows as wf_mod mock_fn = AsyncMock() monkeypatch.setattr("helpers.check_service_health", mock_fn) cache = {"llama-server": True} import asyncio result = asyncio.run( wf_mod.check_workflow_dependencies(["ollama"], health_cache=cache) ) assert result["ollama"] is True mock_fn.assert_not_called() # --------------------------------------------------------------------------- # check_n8n_available() unit tests # --------------------------------------------------------------------------- def test_check_n8n_available_success(test_client): """check_n8n_available returns True when n8n responds < 500.""" import routers.workflows as wf_mod resp_mock = AsyncMock() resp_mock.status = 200 ctx = AsyncMock() ctx.__aenter__ = AsyncMock(return_value=resp_mock) ctx.__aexit__ = AsyncMock(return_value=False) session_mock = AsyncMock() session_mock.get = MagicMock(return_value=ctx) async def fake_get_session(): return session_mock with patch("helpers._get_aio_session", side_effect=fake_get_session): import asyncio result = asyncio.run(wf_mod.check_n8n_available()) assert result is True def test_check_n8n_available_failure(test_client): """check_n8n_available returns False on connection error.""" import routers.workflows as wf_mod session_mock = AsyncMock() session_mock.get = MagicMock(side_effect=aiohttp.ClientError("refused")) async def fake_get_session(): return session_mock with patch("helpers._get_aio_session", side_effect=fake_get_session): import asyncio result = asyncio.run(wf_mod.check_n8n_available()) assert result is False # --------------------------------------------------------------------------- # /api/workflows/categories endpoint # --------------------------------------------------------------------------- def test_workflow_categories_requires_auth(test_client): """GET /api/workflows/categories without auth → 401.""" resp = test_client.get("/api/workflows/categories") assert resp.status_code == 401 def test_workflow_categories_returns_catalog_categories(test_client, tmp_path, monkeypatch): """GET /api/workflows/categories → 200, returns categories from catalog.""" import routers.workflows as wf_mod catalog = { "workflows": [], "categories": {"automation": {"name": "Automation", "icon": "Cog"}} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) resp = test_client.get("/api/workflows/categories", headers=test_client.auth_headers) assert resp.status_code == 200 data = resp.json() assert "categories" in data assert "automation" in data["categories"] # --------------------------------------------------------------------------- # /api/workflows/n8n/status endpoint # --------------------------------------------------------------------------- def test_n8n_status_requires_auth(test_client): """GET /api/workflows/n8n/status without auth → 401.""" resp = test_client.get("/api/workflows/n8n/status") assert resp.status_code == 401 def test_n8n_status_available(test_client): """GET /api/workflows/n8n/status → 200, returns available=True when n8n responds.""" resp_mock = AsyncMock() resp_mock.status = 200 ctx = AsyncMock() ctx.__aenter__ = AsyncMock(return_value=resp_mock) ctx.__aexit__ = AsyncMock(return_value=False) session_mock = AsyncMock() session_mock.get = MagicMock(return_value=ctx) async def fake_get_session(): return session_mock with patch("helpers._get_aio_session", side_effect=fake_get_session): resp = test_client.get("/api/workflows/n8n/status", headers=test_client.auth_headers) assert resp.status_code == 200 data = resp.json() assert data["available"] is True assert "url" in data def test_n8n_status_unavailable(test_client): """GET /api/workflows/n8n/status → 200, returns available=False when n8n is down.""" session_mock = AsyncMock() session_mock.get = MagicMock(side_effect=aiohttp.ClientError("refused")) async def fake_get_session(): return session_mock with patch("helpers._get_aio_session", side_effect=fake_get_session): resp = test_client.get("/api/workflows/n8n/status", headers=test_client.auth_headers) assert resp.status_code == 200 data = resp.json() assert data["available"] is False # --------------------------------------------------------------------------- # /api/workflows/{id}/enable — invalid ID format (400) # --------------------------------------------------------------------------- def test_workflow_enable_invalid_id_format(test_client): """POST /api/workflows/{id}/enable with special chars → 400.""" resp = test_client.post( "/api/workflows/bad!id@here/enable", headers=test_client.auth_headers, ) assert resp.status_code == 400 assert "Invalid workflow ID format" in resp.json()["detail"] # --------------------------------------------------------------------------- # /api/workflows/{id}/enable — missing dependencies (400) # --------------------------------------------------------------------------- @pytest.mark.parametrize( ("method", "path"), [ ("post", "/api/workflows/bad!id@here/disable"), ("delete", "/api/workflows/bad!id@here"), ("get", "/api/workflows/bad!id@here/executions"), ], ) def test_workflow_id_mutation_routes_reject_invalid_format(test_client, method, path): resp = getattr(test_client, method)(path, headers=test_client.auth_headers) assert resp.status_code == 400 assert "Invalid workflow ID format" in resp.json()["detail"] def test_workflow_enable_missing_deps(test_client, tmp_path, monkeypatch): """POST /api/workflows/{id}/enable → 400 when dependencies are not running.""" import routers.workflows as wf_mod from models import ServiceStatus catalog = { "workflows": [ {"id": "dep-wf", "name": "Dep Workflow", "description": "needs llama", "file": "dep-wf.json", "dependencies": ["llama-server"]} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) # Ensure "llama-server" is in SERVICES so the code path actually calls check_service_health monkeypatch.setitem(wf_mod.SERVICES, "llama-server", {"name": "LLM Server", "port": 8080}) unhealthy_status = ServiceStatus( id="llama-server", name="LLM Server", port=8080, external_port=8080, status="down", ) monkeypatch.setattr("helpers.check_service_health", AsyncMock(return_value=unhealthy_status)) resp = test_client.post( "/api/workflows/dep-wf/enable", headers=test_client.auth_headers, ) assert resp.status_code == 400 assert "Missing dependencies" in resp.json()["detail"] # --------------------------------------------------------------------------- # /api/workflows/{id}/enable — workflow file not found (404) # --------------------------------------------------------------------------- def test_workflow_enable_file_not_found(test_client, tmp_path, monkeypatch): """POST /api/workflows/{id}/enable → 404 when workflow JSON file is missing.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "file-wf", "name": "File Workflow", "description": "no file", "file": "missing.json", "dependencies": []} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) workflow_dir = tmp_path / "workflows" workflow_dir.mkdir() monkeypatch.setattr(wf_mod, "WORKFLOW_DIR", workflow_dir) resp = test_client.post( "/api/workflows/file-wf/enable", headers=test_client.auth_headers, ) assert resp.status_code == 404 assert "Workflow file not found" in resp.json()["detail"] # --------------------------------------------------------------------------- # /api/workflows/{id}/disable POST endpoint # --------------------------------------------------------------------------- def test_workflow_disable_post_requires_auth(test_client): """POST /api/workflows/{id}/disable without auth → 401.""" resp = test_client.post("/api/workflows/test-wf/disable") assert resp.status_code == 401 def test_workflow_disable_post_not_in_catalog(test_client): """POST /api/workflows/{id}/disable → 404 when workflow not in catalog.""" with patch("routers.workflows.get_n8n_workflows", new_callable=AsyncMock, return_value=[]): resp = test_client.post( "/api/workflows/nonexistent/disable", headers=test_client.auth_headers, ) assert resp.status_code == 404 def test_workflow_disable_post_not_installed(test_client, tmp_path, monkeypatch): """POST /api/workflows/{id}/disable → 404 when workflow not installed in n8n.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "dis-wf", "name": "Disable Me", "description": "test", "file": "dis.json", "dependencies": []} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) with patch("routers.workflows.get_n8n_workflows", new_callable=AsyncMock, return_value=[]): resp = test_client.post( "/api/workflows/dis-wf/disable", headers=test_client.auth_headers, ) assert resp.status_code == 404 assert "not installed" in resp.json()["detail"] # --------------------------------------------------------------------------- # /api/workflows/{id}/executions — installed workflow returns executions # --------------------------------------------------------------------------- def test_workflow_executions_not_installed(test_client, tmp_path, monkeypatch): """GET /api/workflows/{id}/executions → empty list when workflow not in n8n.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "exec-wf", "name": "Exec Workflow", "description": "test", "file": "exec.json", "dependencies": []} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) with patch("routers.workflows.get_n8n_workflows", new_callable=AsyncMock, return_value=[]): resp = test_client.get( "/api/workflows/exec-wf/executions", headers=test_client.auth_headers, ) assert resp.status_code == 200 data = resp.json() assert data["executions"] == [] assert "not installed" in data["message"].lower() def test_workflow_executions_installed(test_client, tmp_path, monkeypatch): """GET /api/workflows/{id}/executions → returns n8n execution data.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "exec-wf", "name": "Exec Workflow", "description": "test", "file": "exec.json", "dependencies": []} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) n8n_workflows = [{"id": "42", "name": "Exec Workflow", "active": True}] executions_data = {"data": [{"id": "100", "finished": True, "mode": "trigger"}]} # Mock get_n8n_workflows to return our installed workflow with patch("routers.workflows.get_n8n_workflows", new_callable=AsyncMock, return_value=n8n_workflows): # Mock the aiohttp session for the executions API call resp_mock = AsyncMock() resp_mock.status = 200 resp_mock.json = AsyncMock(return_value=executions_data) ctx = AsyncMock() ctx.__aenter__ = AsyncMock(return_value=resp_mock) ctx.__aexit__ = AsyncMock(return_value=False) session_mock = AsyncMock() session_mock.get = MagicMock(return_value=ctx) session_mock.__aenter__ = AsyncMock(return_value=session_mock) session_mock.__aexit__ = AsyncMock(return_value=False) with patch("routers.workflows.aiohttp.ClientSession", return_value=session_mock): resp = test_client.get( "/api/workflows/exec-wf/executions", headers=test_client.auth_headers, ) assert resp.status_code == 200 data = resp.json() assert data["workflowId"] == "exec-wf" assert data["n8nId"] == "42" assert len(data["executions"]) == 1 # --------------------------------------------------------------------------- # /api/workflows/{id}/enable — full import to n8n # --------------------------------------------------------------------------- def test_workflow_enable_success(test_client, tmp_path, monkeypatch): """POST /api/workflows/{id}/enable → 200, imports workflow to n8n.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "ok-wf", "name": "OK Workflow", "description": "test", "file": "ok-wf.json", "dependencies": []} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) workflow_dir = tmp_path / "workflows" workflow_dir.mkdir() (workflow_dir / "ok-wf.json").write_text(json.dumps({"name": "OK Workflow", "nodes": []})) monkeypatch.setattr(wf_mod, "WORKFLOW_DIR", workflow_dir) # Mock n8n POST (create) → 201 with id create_resp = AsyncMock() create_resp.status = 201 create_resp.json = AsyncMock(return_value={"data": {"id": "n8n-99"}}) # Mock n8n PATCH (activate) → 200 activate_resp = AsyncMock() activate_resp.status = 200 create_ctx = AsyncMock() create_ctx.__aenter__ = AsyncMock(return_value=create_resp) create_ctx.__aexit__ = AsyncMock(return_value=False) activate_ctx = AsyncMock() activate_ctx.__aenter__ = AsyncMock(return_value=activate_resp) activate_ctx.__aexit__ = AsyncMock(return_value=False) session_mock = AsyncMock() session_mock.post = MagicMock(return_value=create_ctx) session_mock.patch = MagicMock(return_value=activate_ctx) session_mock.__aenter__ = AsyncMock(return_value=session_mock) session_mock.__aexit__ = AsyncMock(return_value=False) with patch("routers.workflows.aiohttp.ClientSession", return_value=session_mock): resp = test_client.post( "/api/workflows/ok-wf/enable", headers=test_client.auth_headers, ) assert resp.status_code == 200 data = resp.json() assert data["status"] == "success" assert data["n8nId"] == "n8n-99" assert data["activated"] is True def test_workflow_enable_n8n_error(test_client, tmp_path, monkeypatch): """POST /api/workflows/{id}/enable → n8n returns error status.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "err-wf", "name": "Err Workflow", "description": "test", "file": "err-wf.json", "dependencies": []} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) workflow_dir = tmp_path / "workflows" workflow_dir.mkdir() (workflow_dir / "err-wf.json").write_text(json.dumps({"name": "Err"})) monkeypatch.setattr(wf_mod, "WORKFLOW_DIR", workflow_dir) create_resp = AsyncMock() create_resp.status = 400 create_resp.text = AsyncMock(return_value="validation error") create_ctx = AsyncMock() create_ctx.__aenter__ = AsyncMock(return_value=create_resp) create_ctx.__aexit__ = AsyncMock(return_value=False) session_mock = AsyncMock() session_mock.post = MagicMock(return_value=create_ctx) session_mock.__aenter__ = AsyncMock(return_value=session_mock) session_mock.__aexit__ = AsyncMock(return_value=False) with patch("routers.workflows.aiohttp.ClientSession", return_value=session_mock): resp = test_client.post( "/api/workflows/err-wf/enable", headers=test_client.auth_headers, ) assert resp.status_code == 400 def test_workflow_enable_n8n_unreachable(test_client, tmp_path, monkeypatch): """POST /api/workflows/{id}/enable → 503 when n8n unreachable.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "net-wf", "name": "Net Workflow", "description": "test", "file": "net-wf.json", "dependencies": []} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) workflow_dir = tmp_path / "workflows" workflow_dir.mkdir() (workflow_dir / "net-wf.json").write_text(json.dumps({"name": "Net"})) monkeypatch.setattr(wf_mod, "WORKFLOW_DIR", workflow_dir) session_mock = AsyncMock() session_mock.post = MagicMock(side_effect=aiohttp.ClientError("refused")) session_mock.__aenter__ = AsyncMock(return_value=session_mock) session_mock.__aexit__ = AsyncMock(return_value=False) with patch("routers.workflows.aiohttp.ClientSession", return_value=session_mock): resp = test_client.post( "/api/workflows/net-wf/enable", headers=test_client.auth_headers, ) assert resp.status_code == 503 # --------------------------------------------------------------------------- # /api/workflows/{id}/disable — success path (via _remove_workflow) # --------------------------------------------------------------------------- def test_workflow_disable_success(test_client, tmp_path, monkeypatch): """POST /api/workflows/{id}/disable → 200 when workflow found and removed.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "rm-wf", "name": "Remove Me", "description": "test", "file": "rm.json", "dependencies": []} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) n8n_workflows = [{"id": "77", "name": "Remove Me", "active": True}] del_resp = AsyncMock() del_resp.status = 204 del_ctx = AsyncMock() del_ctx.__aenter__ = AsyncMock(return_value=del_resp) del_ctx.__aexit__ = AsyncMock(return_value=False) session_mock = AsyncMock() session_mock.delete = MagicMock(return_value=del_ctx) session_mock.__aenter__ = AsyncMock(return_value=session_mock) session_mock.__aexit__ = AsyncMock(return_value=False) with patch("routers.workflows.get_n8n_workflows", new_callable=AsyncMock, return_value=n8n_workflows), \ patch("routers.workflows.aiohttp.ClientSession", return_value=session_mock): resp = test_client.post( "/api/workflows/rm-wf/disable", headers=test_client.auth_headers, ) assert resp.status_code == 200 assert resp.json()["status"] == "success" # --------------------------------------------------------------------------- # /api/workflows/{id}/executions — error fallback # --------------------------------------------------------------------------- def test_workflow_executions_n8n_error(test_client, tmp_path, monkeypatch): """GET /api/workflows/{id}/executions → returns error when n8n call fails.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "ex-wf", "name": "Ex Workflow", "description": "test", "file": "ex.json", "dependencies": []} ], "categories": {} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) n8n_workflows = [{"id": "88", "name": "Ex Workflow", "active": True}] session_mock = AsyncMock() session_mock.get = MagicMock(side_effect=aiohttp.ClientError("refused")) session_mock.__aenter__ = AsyncMock(return_value=session_mock) session_mock.__aexit__ = AsyncMock(return_value=False) with patch("routers.workflows.get_n8n_workflows", new_callable=AsyncMock, return_value=n8n_workflows), \ patch("routers.workflows.aiohttp.ClientSession", return_value=session_mock): resp = test_client.get( "/api/workflows/ex-wf/executions", headers=test_client.auth_headers, ) assert resp.status_code == 200 data = resp.json() assert data["executions"] == [] assert "error" in data # --------------------------------------------------------------------------- # /api/workflows — full endpoint (covers lines 117-149) # --------------------------------------------------------------------------- def test_workflows_with_matching_n8n(test_client, tmp_path, monkeypatch): """GET /api/workflows with a workflow that matches an n8n workflow.""" import routers.workflows as wf_mod catalog = { "workflows": [ {"id": "chat-wf", "name": "Chat Assistant", "description": "Chat bot", "file": "chat.json", "dependencies": [], "icon": "MessageSquare", "category": "general", "featured": True, "setupTime": "~1 min", "diagram": {"nodes": 3}} ], "categories": {"general": {"name": "General", "icon": "Cog"}} } catalog_file = tmp_path / "catalog.json" catalog_file.write_text(json.dumps(catalog)) monkeypatch.setattr(wf_mod, "WORKFLOW_CATALOG_FILE", catalog_file) n8n_workflows = [{"id": "55", "name": "Chat Assistant", "active": True, "statistics": {"executions": {"total": 42}}}] resp_mock = AsyncMock() resp_mock.status = 200 resp_mock.json = AsyncMock(return_value={"data": n8n_workflows}) ctx = AsyncMock() ctx.__aenter__ = AsyncMock(return_value=resp_mock) ctx.__aexit__ = AsyncMock(return_value=False) session_mock = AsyncMock() session_mock.get = MagicMock(return_value=ctx) session_mock.__aenter__ = AsyncMock(return_value=session_mock) session_mock.__aexit__ = AsyncMock(return_value=False) with patch("routers.workflows.aiohttp.ClientSession", return_value=session_mock): resp = test_client.get("/api/workflows", headers=test_client.auth_headers) assert resp.status_code == 200 data = resp.json() wf = next(w for w in data["workflows"] if w["id"] == "chat-wf") assert wf["installed"] is True assert wf["active"] is True assert wf["n8nId"] == "55" assert wf["executions"] == 42 assert wf["featured"] is True