kin/tests/test_api_phases.py
Gros Frumos 4188384f1b kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00

314 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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