"""Tests for web/api.py — Phase endpoints (KIN-059). Covers: - POST /api/projects/new — создание проекта с фазами - GET /api/projects/{id}/phases — список фаз с joined task - POST /api/phases/{id}/approve — approve фазы - POST /api/phases/{id}/reject — reject фазы - POST /api/phases/{id}/revise — revise фазы - POST /api/projects/{id}/phases/start — запуск агента для активной фазы """ from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient import web.api as api_module @pytest.fixture def client(tmp_path): """KIN-059: TestClient с изолированной временной БД.""" db_path = tmp_path / "test.db" api_module.DB_PATH = db_path from web.api import app return TestClient(app) @pytest.fixture def client_with_phases(client): """KIN-059: клиент с уже созданным проектом + фазами.""" r = client.post("/api/projects/new", json={ "id": "proj1", "name": "Test Project", "path": "/tmp/proj1", "description": "Описание тестового проекта", "roles": ["business_analyst"], }) assert r.status_code == 200 return client # --------------------------------------------------------------------------- # POST /api/projects/new # --------------------------------------------------------------------------- def test_post_projects_new_creates_project_and_phases(client): """KIN-059: POST /api/projects/new создаёт проект с фазами (researcher + architect).""" r = client.post("/api/projects/new", json={ "id": "p1", "name": "My Project", "path": "/tmp/p1", "description": "Описание", "roles": ["tech_researcher"], }) assert r.status_code == 200 data = r.json() assert data["project"]["id"] == "p1" # tech_researcher + architect = 2 фазы assert len(data["phases"]) == 2 phase_roles = [ph["role"] for ph in data["phases"]] assert "architect" in phase_roles assert phase_roles[-1] == "architect" def test_post_projects_new_no_roles_returns_400(client): """KIN-059: POST /api/projects/new без ролей возвращает 400.""" r = client.post("/api/projects/new", json={ "id": "p1", "name": "P1", "path": "/tmp/p1", "description": "Desc", "roles": [], }) assert r.status_code == 400 def test_post_projects_new_only_architect_returns_400(client): """KIN-059: только architect в roles → 400 (architect не researcher).""" r = client.post("/api/projects/new", json={ "id": "p1", "name": "P1", "path": "/tmp/p1", "description": "Desc", "roles": ["architect"], }) assert r.status_code == 400 def test_post_projects_new_duplicate_id_returns_409(client): """KIN-059: повторное создание проекта с тем же id → 409.""" payload = { "id": "dup", "name": "Dup", "path": "/tmp/dup", "description": "Desc", "roles": ["marketer"], } client.post("/api/projects/new", json=payload) r = client.post("/api/projects/new", json=payload) assert r.status_code == 409 def test_post_projects_new_first_phase_is_active(client): """KIN-059: первая фаза созданного проекта сразу имеет status=active.""" r = client.post("/api/projects/new", json={ "id": "p1", "name": "P1", "path": "/tmp/p1", "description": "Desc", "roles": ["market_researcher", "tech_researcher"], }) assert r.status_code == 200 first_phase = r.json()["phases"][0] assert first_phase["status"] == "active" # --------------------------------------------------------------------------- # GET /api/projects/{project_id}/phases # --------------------------------------------------------------------------- def test_get_project_phases_returns_phases_with_task(client_with_phases): """KIN-059: GET /api/projects/{id}/phases возвращает фазы с joined полем task.""" r = client_with_phases.get("/api/projects/proj1/phases") assert r.status_code == 200 phases = r.json() assert len(phases) >= 1 # Активная первая фаза должна иметь task active = next((ph for ph in phases if ph["status"] == "active"), None) assert active is not None assert active["task"] is not None def test_get_project_phases_project_not_found_returns_404(client): """KIN-059: GET /api/projects/missing/phases → 404.""" r = client.get("/api/projects/missing/phases") assert r.status_code == 404 # --------------------------------------------------------------------------- # POST /api/phases/{phase_id}/approve # --------------------------------------------------------------------------- def _get_first_active_phase_id(client, project_id: str) -> int: """Вспомогательная: получить id первой активной фазы.""" phases = client.get(f"/api/projects/{project_id}/phases").json() active = next(ph for ph in phases if ph["status"] == "active") return active["id"] def test_approve_phase_returns_200_and_activates_next(client_with_phases): """KIN-059: POST /api/phases/{id}/approve → 200, следующая фаза активируется.""" phase_id = _get_first_active_phase_id(client_with_phases, "proj1") r = client_with_phases.post(f"/api/phases/{phase_id}/approve", json={}) assert r.status_code == 200 data = r.json() assert data["phase"]["status"] == "approved" # Следующая фаза (architect) активирована assert data["next_phase"] is not None assert data["next_phase"]["status"] == "active" def test_approve_phase_not_found_returns_404(client): """KIN-059: approve несуществующей фазы → 404.""" r = client.post("/api/phases/9999/approve", json={}) assert r.status_code == 404 def test_approve_phase_not_active_returns_400(client): """KIN-059: approve pending-фазы → 400 (фаза не active).""" # Создаём проект с двумя researcher-ролями client.post("/api/projects/new", json={ "id": "p2", "name": "P2", "path": "/tmp/p2", "description": "Desc", "roles": ["market_researcher", "tech_researcher"], }) phases = client.get("/api/projects/p2/phases").json() # Вторая фаза pending pending = next(ph for ph in phases if ph["status"] == "pending") r = client.post(f"/api/phases/{pending['id']}/approve", json={}) assert r.status_code == 400 # --------------------------------------------------------------------------- # POST /api/phases/{phase_id}/reject # --------------------------------------------------------------------------- def test_reject_phase_returns_200(client_with_phases): """KIN-059: POST /api/phases/{id}/reject → 200, status=rejected.""" phase_id = _get_first_active_phase_id(client_with_phases, "proj1") r = client_with_phases.post(f"/api/phases/{phase_id}/reject", json={"reason": "Не актуально"}) assert r.status_code == 200 assert r.json()["status"] == "rejected" def test_reject_phase_not_found_returns_404(client): """KIN-059: reject несуществующей фазы → 404.""" r = client.post("/api/phases/9999/reject", json={"reason": "test"}) assert r.status_code == 404 def test_reject_phase_not_active_returns_400(client): """KIN-059: reject pending-фазы → 400.""" client.post("/api/projects/new", json={ "id": "p3", "name": "P3", "path": "/tmp/p3", "description": "Desc", "roles": ["legal_researcher", "ux_designer"], }) phases = client.get("/api/projects/p3/phases").json() pending = next(ph for ph in phases if ph["status"] == "pending") r = client.post(f"/api/phases/{pending['id']}/reject", json={"reason": "test"}) assert r.status_code == 400 # --------------------------------------------------------------------------- # POST /api/phases/{phase_id}/revise # --------------------------------------------------------------------------- def test_revise_phase_returns_200_and_creates_new_task(client_with_phases): """KIN-059: POST /api/phases/{id}/revise → 200, создаётся новая задача.""" phase_id = _get_first_active_phase_id(client_with_phases, "proj1") r = client_with_phases.post( f"/api/phases/{phase_id}/revise", json={"comment": "Добавь детали по монетизации"}, ) assert r.status_code == 200 data = r.json() assert data["phase"]["status"] == "revising" assert data["new_task"] is not None assert data["new_task"]["brief"]["revise_comment"] == "Добавь детали по монетизации" def test_revise_phase_empty_comment_returns_400(client_with_phases): """KIN-059: revise с пустым комментарием → 400.""" phase_id = _get_first_active_phase_id(client_with_phases, "proj1") r = client_with_phases.post(f"/api/phases/{phase_id}/revise", json={"comment": " "}) assert r.status_code == 400 def test_revise_phase_not_found_returns_404(client): """KIN-059: revise несуществующей фазы → 404.""" r = client.post("/api/phases/9999/revise", json={"comment": "test"}) assert r.status_code == 404 def test_revise_phase_not_active_returns_400(client): """KIN-059: revise pending-фазы → 400.""" client.post("/api/projects/new", json={ "id": "p4", "name": "P4", "path": "/tmp/p4", "description": "Desc", "roles": ["marketer", "ux_designer"], }) phases = client.get("/api/projects/p4/phases").json() pending = next(ph for ph in phases if ph["status"] == "pending") r = client.post(f"/api/phases/{pending['id']}/revise", json={"comment": "test"}) assert r.status_code == 400 # --------------------------------------------------------------------------- # POST /api/projects/{project_id}/phases/start # --------------------------------------------------------------------------- def test_start_phase_returns_202_and_starts_agent(client_with_phases): """KIN-059: POST /api/projects/{id}/phases/start → 202, агент запускается в фоне.""" with patch("subprocess.Popen") as mock_popen: mock_proc = MagicMock() mock_proc.pid = 12345 mock_popen.return_value = mock_proc r = client_with_phases.post("/api/projects/proj1/phases/start") assert r.status_code == 202 data = r.json() assert data["status"] == "started" assert "phase_id" in data assert "task_id" in data mock_popen.assert_called_once() def test_start_phase_task_set_to_in_progress(client_with_phases): """KIN-059: start устанавливает task.status=in_progress перед запуском агента.""" with patch("subprocess.Popen") as mock_popen: mock_popen.return_value = MagicMock(pid=1) r = client_with_phases.post("/api/projects/proj1/phases/start") task_id = r.json()["task_id"] task = client_with_phases.get(f"/api/tasks/{task_id}").json() assert task["status"] == "in_progress" def test_start_phase_no_active_phase_returns_404(client): """KIN-059: start без активной/revising фазы → 404.""" # Проект без фаз (обычный проект через /api/projects) client.post("/api/projects", json={"id": "plain", "name": "Plain", "path": "/tmp/plain"}) r = client.post("/api/projects/plain/phases/start") assert r.status_code == 404 def test_start_phase_project_not_found_returns_404(client): """KIN-059: start для несуществующего проекта → 404.""" r = client.post("/api/projects/missing/phases/start") assert r.status_code == 404