"""v0.4 — Workflow generator UI + hot-reload tests. Covers: 1. POST /api/workflows persists a YAML under /workflows/ 2. POST rejects malformed body with 422 3. POST rejects duplicate (name, version) with 409 4. GET /api/workflows hot-reloads when a new file appears 5. GET /api/workflows hot-reloads when an existing file is edited 6. /new-workflow.html serves with the page marker """ from __future__ import annotations from collections.abc import AsyncIterator from pathlib import Path import pytest import yaml from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from my_deepagent.api.app import create_app from my_deepagent.config import load_config from my_deepagent.persistence.db import Database @pytest.fixture async def app_client(tmp_path: Path) -> AsyncIterator[tuple[AsyncClient, FastAPI, Path]]: db_url = f"sqlite+aiosqlite:///{tmp_path / 'gen.sqlite3'}" cfg = load_config( workspace_root=tmp_path, data_dir=tmp_path / "data", database_url=db_url, ) db = Database(db_url) await db.init_schema() await db.dispose() app = create_app(cfg) transport = ASGITransport(app=app) async with app.router.lifespan_context(app): async with AsyncClient(transport=transport, base_url="http://test", timeout=10.0) as client: yield (client, app, cfg.data_dir) def _valid_body(name: str = "my-flow", version: int = 1) -> dict[str, object]: return { "name": name, "version": version, "description": "test workflow generator", "roles": [{"id": "writer", "required_capabilities": ["code_edit"]}], "phases": [ { "key": "p1", "title": "first phase", "risk": "medium", "role": "writer", "instructions": "do something useful in this phase", } ], } # --------------------------------------------------------------------------- # Static page # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_new_workflow_page_served( app_client: tuple[AsyncClient, FastAPI, Path], ) -> None: client, _app, _dir = app_client r = await client.get("/new-workflow.html") assert r.status_code == 200 assert 'data-page="new-workflow"' in r.text assert "워크플로우 템플릿 만들기" in r.text # --------------------------------------------------------------------------- # POST /api/workflows happy path # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_post_workflow_creates_yaml_under_data_dir( app_client: tuple[AsyncClient, FastAPI, Path], ) -> None: client, _app, data_dir = app_client r = await client.post("/api/workflows", json=_valid_body()) assert r.status_code == 201, r.text body = r.json() target = Path(body["path"]) assert target.is_file() assert target.parent == data_dir / "workflows" assert target.name == "my-flow@1.yaml" parsed = yaml.safe_load(target.read_text(encoding="utf-8")) assert parsed["name"] == "my-flow" assert parsed["version"] == 1 assert parsed["phases"][0]["key"] == "p1" # --------------------------------------------------------------------------- # Validation rejection # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_post_workflow_rejects_missing_roles( app_client: tuple[AsyncClient, FastAPI, Path], ) -> None: client, _app, _dir = app_client bad = _valid_body() bad["roles"] = [] # min_length=1 violation r = await client.post("/api/workflows", json=bad) assert r.status_code == 422 @pytest.mark.asyncio async def test_post_workflow_rejects_phase_referencing_unknown_role( app_client: tuple[AsyncClient, FastAPI, Path], ) -> None: client, _app, _dir = app_client bad = _valid_body() bad["phases"][0]["role"] = "ghost-role" # type: ignore[index] r = await client.post("/api/workflows", json=bad) assert r.status_code == 422 assert "ghost-role" in r.text # --------------------------------------------------------------------------- # Duplicate refusal # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_post_workflow_rejects_duplicate_name_version( app_client: tuple[AsyncClient, FastAPI, Path], ) -> None: client, _app, _dir = app_client body = _valid_body("dup-flow", 1) r1 = await client.post("/api/workflows", json=body) assert r1.status_code == 201 r2 = await client.post("/api/workflows", json=body) assert r2.status_code == 409 assert "already exists" in r2.text # --------------------------------------------------------------------------- # Hot-reload — new file appears in GET # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_workflows_hot_reloads_after_post( app_client: tuple[AsyncClient, FastAPI, Path], ) -> None: client, _app, _dir = app_client before = await client.get("/api/workflows") before_names = {w["name"] for w in before.json()} assert "fresh-flow" not in before_names r = await client.post("/api/workflows", json=_valid_body("fresh-flow", 1)) assert r.status_code == 201 after = await client.get("/api/workflows") after_names = {w["name"] for w in after.json()} assert "fresh-flow" in after_names @pytest.mark.asyncio async def test_get_workflows_hot_reloads_after_external_file_drop( app_client: tuple[AsyncClient, FastAPI, Path], ) -> None: """Even when the file is dropped directly into the dir (not via POST), the next GET picks it up via the mtime fingerprint.""" from textwrap import dedent client, _app, data_dir = app_client wf_dir = data_dir / "workflows" wf_dir.mkdir(parents=True, exist_ok=True) (wf_dir / "external@1.yaml").write_text( dedent( """\ name: external version: 1 description: dropped by hand roles: - id: writer required_capabilities: [code_edit] phases: - key: p1 title: only phase risk: low role: writer instructions: just write something to disk """ ), encoding="utf-8", ) r = await client.get("/api/workflows") names = {w["name"] for w in r.json()} assert "external" in names