diff --git a/core/phases.py b/core/phases.py index 26a3a91..1e08bac 100644 --- a/core/phases.py +++ b/core/phases.py @@ -60,6 +60,7 @@ def create_project_with_phases( id: str, name: str, path: str | None = None, + *, description: str, selected_roles: list[str], tech_stack: list | None = None, diff --git a/tests/test_api.py b/tests/test_api.py index 8f46753..c928a13 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1267,6 +1267,83 @@ def test_kin016_pipeline_blocked_agent_stops_next_steps_integration(client): assert items[0]["agent_role"] == "debugger" +# --------------------------------------------------------------------------- +# KIN-BIZ-001 — telegram_sent из БД (не заглушка) +# --------------------------------------------------------------------------- + +def test_kin_biz_001_telegram_sent_not_stub(client): + """Регрессия KIN-BIZ-001: /api/notifications возвращает реальный telegram_sent из БД, не False-заглушку.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task( + conn, "P1-001", + status="blocked", + blocked_reason="cannot access repo", + blocked_agent_role="debugger", + ) + models.mark_telegram_sent(conn, "P1-001") + conn.close() + + r = client.get("/api/notifications") + assert r.status_code == 200 + items = r.json() + assert len(items) == 1 + # Ключевая проверка: telegram_sent должен быть True из БД, не False-заглушка + assert items[0]["telegram_sent"] is True + + +def test_kin_biz_001_notifications_telegram_sent_false_when_not_sent(client): + """KIN-BIZ-001: telegram_sent=False для задачи, где уведомление не отправлялось.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.update_task( + conn, "P1-001", + status="blocked", + blocked_reason="no access", + blocked_agent_role="tester", + ) + # Не вызываем mark_telegram_sent → telegram_sent остаётся 0 + conn.close() + + r = client.get("/api/notifications") + assert r.status_code == 200 + items = r.json() + assert len(items) == 1 + assert items[0]["telegram_sent"] is False + + +def test_kin_biz_001_telegram_sent_distinguishes_sent_and_not_sent(client): + """KIN-BIZ-001: список уведомлений корректно различает sent/not-sent задачи.""" + from core.db import init_db + from core import models + conn = init_db(api_module.DB_PATH) + models.create_task(conn, "P1-002", "p1", "Another task") + models.update_task( + conn, "P1-001", + status="blocked", + blocked_reason="reason 1", + blocked_agent_role="debugger", + ) + models.update_task( + conn, "P1-002", + status="blocked", + blocked_reason="reason 2", + blocked_agent_role="tester", + ) + # Telegram отправлен только для P1-001 + models.mark_telegram_sent(conn, "P1-001") + conn.close() + + r = client.get("/api/notifications") + assert r.status_code == 200 + items = r.json() + assert len(items) == 2 + by_id = {item["task_id"]: item for item in items} + assert by_id["P1-001"]["telegram_sent"] is True + assert by_id["P1-002"]["telegram_sent"] is False + # --------------------------------------------------------------------------- # KIN-071: project_type и SSH-поля в API diff --git a/tests/test_kin_biz_002.py b/tests/test_kin_biz_002.py new file mode 100644 index 0000000..bd1b2e0 --- /dev/null +++ b/tests/test_kin_biz_002.py @@ -0,0 +1,200 @@ +"""Regression tests for KIN-BIZ-002. + +Проблема: approve через /tasks/{id}/approve не продвигал phase state machine. +Фикс: в approve_task() добавлен блок, вызывающий approve_phase() из core.phases + если задача принадлежит активной фазе. + В approve_phase() endpoint добавлена синхронизация task.status='done'. + +Тесты покрывают: + 1. POST /tasks/{id}/approve для phase-задачи → phase.status=done, следующая фаза active + 2. Изменения в БД персистентны после approve + 3. POST /tasks/{id}/approve для обычной задачи → не ломает ничего, phase=None + 4. POST /phases/{id}/approve → task.status синхронизируется в done +""" + +import pytest +from fastapi.testclient import TestClient + +import web.api as api_module + + +@pytest.fixture +def client(tmp_path): + """Изолированная временная БД для каждого теста.""" + db_path = tmp_path / "test_biz002.db" + api_module.DB_PATH = db_path + from web.api import app + return TestClient(app) + + +def _create_project_with_phases(client, project_id: str = "proj_biz002") -> dict: + """Вспомогательная: создаёт проект с двумя researcher-фазами + architect.""" + r = client.post("/api/projects/new", json={ + "id": project_id, + "name": "BIZ-002 Test Project", + "path": f"/tmp/{project_id}", + "description": "Тест регрессии KIN-BIZ-002", + "roles": ["business_analyst", "tech_researcher"], + }) + assert r.status_code == 200, r.json() + return r.json() + + +def _get_active_phase(client, project_id: str) -> dict: + """Вспомогательная: возвращает первую активную фазу.""" + phases = client.get(f"/api/projects/{project_id}/phases").json() + active = next(ph for ph in phases if ph["status"] == "active") + return active + + +# --------------------------------------------------------------------------- +# KIN-BIZ-002 — регрессионные тесты +# --------------------------------------------------------------------------- + + +def test_KIN_BIZ_002_approve_task_advances_phase_state_machine(client): + """KIN-BIZ-002: POST /tasks/{id}/approve для phase-задачи продвигает state machine. + + Ожидаем: phase.status=approved, next_phase активирован. + """ + _create_project_with_phases(client) + active_phase = _get_active_phase(client, "proj_biz002") + task_id = active_phase["task_id"] + + r = client.post(f"/api/tasks/{task_id}/approve", json={}) + + assert r.status_code == 200 + data = r.json() + assert data["status"] == "done" + # Ключ phase должен присутствовать и содержать результат + assert "phase" in data + assert data["phase"] is not None + # Одобренная фаза имеет status=approved + assert data["phase"]["phase"]["status"] == "approved" + # Следующая фаза была активирована + assert data["phase"]["next_phase"] is not None + assert data["phase"]["next_phase"]["status"] == "active" + + +def test_KIN_BIZ_002_approve_task_phase_status_persists_in_db(client): + """KIN-BIZ-002: после approve через /tasks/{id}/approve статусы фаз корректны в БД. + + Первая фаза → approved, вторая фаза → active. + """ + data = _create_project_with_phases(client) + # Три фазы: business_analyst, tech_researcher, architect + assert len(data["phases"]) == 3 + + active_phase = _get_active_phase(client, "proj_biz002") + task_id = active_phase["task_id"] + + client.post(f"/api/tasks/{task_id}/approve", json={}) + + # Перечитываем фазы из БД + phases = client.get("/api/projects/proj_biz002/phases").json() + statuses = {ph["role"]: ph["status"] for ph in phases} + + assert statuses["business_analyst"] == "approved" + assert statuses["tech_researcher"] == "active" + assert statuses["architect"] == "pending" + + +def test_KIN_BIZ_002_approve_task_task_status_is_done(client): + """KIN-BIZ-002: сама задача должна иметь status=done после approve.""" + _create_project_with_phases(client) + active_phase = _get_active_phase(client, "proj_biz002") + task_id = active_phase["task_id"] + + client.post(f"/api/tasks/{task_id}/approve", json={}) + + task = client.get(f"/api/tasks/{task_id}").json() + assert task["status"] == "done" + + +def test_KIN_BIZ_002_approve_regular_task_does_not_affect_phases(client): + """KIN-BIZ-002: approve обычной задачи (без фазы) не ломает ничего, phase=None.""" + # Создаём обычный проект без фаз + client.post("/api/projects", json={ + "id": "plain_proj", + "name": "Plain Project", + "path": "/tmp/plain_proj", + }) + r_task = client.post("/api/tasks", json={ + "project_id": "plain_proj", + "title": "Обычная задача без фазы", + }) + assert r_task.status_code == 200 + task_id = r_task.json()["id"] + + r = client.post(f"/api/tasks/{task_id}/approve", json={}) + + assert r.status_code == 200 + data = r.json() + assert data["status"] == "done" + # phase должен быть None — нет связанной фазы + assert data["phase"] is None + + +def test_KIN_BIZ_002_approve_regular_task_sets_status_done(client): + """KIN-BIZ-002: approve обычной задачи корректно устанавливает status=done.""" + client.post("/api/projects", json={ + "id": "plain2", + "name": "Plain2", + "path": "/tmp/plain2", + }) + r_task = client.post("/api/tasks", json={ + "project_id": "plain2", + "title": "Задача без фазы", + }) + task_id = r_task.json()["id"] + + client.post(f"/api/tasks/{task_id}/approve", json={}) + + task = client.get(f"/api/tasks/{task_id}").json() + assert task["status"] == "done" + + +def test_KIN_BIZ_002_approve_phase_endpoint_syncs_task_status_to_done(client): + """KIN-BIZ-002: POST /phases/{id}/approve синхронизирует task.status=done. + + Гарантируем консистентность обоих путей одобрения фазы. + """ + _create_project_with_phases(client) + active_phase = _get_active_phase(client, "proj_biz002") + phase_id = active_phase["id"] + task_id = active_phase["task_id"] + + r = client.post(f"/api/phases/{phase_id}/approve", json={}) + assert r.status_code == 200 + + # Задача, связанная с фазой, должна иметь status=done + task = client.get(f"/api/tasks/{task_id}").json() + assert task["status"] == "done" + + +def test_KIN_BIZ_002_full_phase_chain_two_approves_completes_workflow(client): + """KIN-BIZ-002: последовательный approve через /tasks/{id}/approve проходит весь chain. + + business_analyst → approved → tech_researcher → approved → architect → approved. + """ + _create_project_with_phases(client) + phases_init = client.get("/api/projects/proj_biz002/phases").json() + assert len(phases_init) == 3 + + # Апруваем каждую фазу последовательно через task-endpoint + for _ in range(3): + phases = client.get("/api/projects/proj_biz002/phases").json() + active = next((ph for ph in phases if ph["status"] == "active"), None) + if active is None: + break + task_id = active["task_id"] + r = client.post(f"/api/tasks/{task_id}/approve", json={}) + assert r.status_code == 200 + assert r.json()["status"] == "done" + + # После всех approve все фазы должны быть approved + final_phases = client.get("/api/projects/proj_biz002/phases").json() + for ph in final_phases: + assert ph["status"] == "approved", ( + f"Ожидали approved для {ph['role']}, получили {ph['status']}" + ) diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 78f9526..a3110de 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -32,9 +32,13 @@ def db_conn(): @pytest.fixture def tg_env(monkeypatch): - """Inject Telegram credentials via env vars (bypass secrets file).""" + """Inject Telegram credentials via env vars (bypass secrets file). + + Also stubs _load_kin_config so the secrets file doesn't override env vars. + """ monkeypatch.setenv("KIN_TG_BOT_TOKEN", "test-token-abc123") monkeypatch.setenv("KIN_TG_CHAT_ID", "99887766") + monkeypatch.setattr("core.telegram._load_kin_config", lambda: {}) @pytest.fixture diff --git a/web/api.py b/web/api.py index 9a077a2..f940a4c 100644 --- a/web/api.py +++ b/web/api.py @@ -115,7 +115,7 @@ def list_projects(status: str | None = None): class NewProjectCreate(BaseModel): id: str name: str - path: str + path: str | None = None description: str roles: list[str] tech_stack: list[str] | None = None @@ -170,7 +170,7 @@ VALID_PROJECT_TYPES = {"development", "operations", "research"} class ProjectCreate(BaseModel): id: str name: str - path: str = "" + path: str | None = None tech_stack: list[str] | None = None status: str = "active" priority: int = 5 @@ -181,9 +181,11 @@ class ProjectCreate(BaseModel): ssh_proxy_jump: str | None = None @model_validator(mode="after") - def validate_operations_ssh_host(self) -> "ProjectCreate": + def validate_fields(self) -> "ProjectCreate": if self.project_type == "operations" and not self.ssh_host: raise ValueError("ssh_host is required for operations projects") + if self.project_type != "operations" and not self.path: + raise ValueError("path is required for non-operations projects") return self