314 lines
12 KiB
Python
314 lines
12 KiB
Python
"""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
|