kin: KIN-BIZ-002 Исправить консистентность: approve через /tasks/{id}/approve не продвигает phase state machine
This commit is contained in:
parent
044bd15b2e
commit
39acc9cc4b
5 changed files with 288 additions and 4 deletions
|
|
@ -60,6 +60,7 @@ def create_project_with_phases(
|
||||||
id: str,
|
id: str,
|
||||||
name: str,
|
name: str,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
|
*,
|
||||||
description: str,
|
description: str,
|
||||||
selected_roles: list[str],
|
selected_roles: list[str],
|
||||||
tech_stack: list | None = None,
|
tech_stack: list | None = None,
|
||||||
|
|
|
||||||
|
|
@ -1267,6 +1267,83 @@ def test_kin016_pipeline_blocked_agent_stops_next_steps_integration(client):
|
||||||
assert items[0]["agent_role"] == "debugger"
|
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
|
# KIN-071: project_type и SSH-поля в API
|
||||||
|
|
|
||||||
200
tests/test_kin_biz_002.py
Normal file
200
tests/test_kin_biz_002.py
Normal file
|
|
@ -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']}"
|
||||||
|
)
|
||||||
|
|
@ -32,9 +32,13 @@ def db_conn():
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tg_env(monkeypatch):
|
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_BOT_TOKEN", "test-token-abc123")
|
||||||
monkeypatch.setenv("KIN_TG_CHAT_ID", "99887766")
|
monkeypatch.setenv("KIN_TG_CHAT_ID", "99887766")
|
||||||
|
monkeypatch.setattr("core.telegram._load_kin_config", lambda: {})
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ def list_projects(status: str | None = None):
|
||||||
class NewProjectCreate(BaseModel):
|
class NewProjectCreate(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
path: str
|
path: str | None = None
|
||||||
description: str
|
description: str
|
||||||
roles: list[str]
|
roles: list[str]
|
||||||
tech_stack: list[str] | None = None
|
tech_stack: list[str] | None = None
|
||||||
|
|
@ -170,7 +170,7 @@ VALID_PROJECT_TYPES = {"development", "operations", "research"}
|
||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
path: str = ""
|
path: str | None = None
|
||||||
tech_stack: list[str] | None = None
|
tech_stack: list[str] | None = None
|
||||||
status: str = "active"
|
status: str = "active"
|
||||||
priority: int = 5
|
priority: int = 5
|
||||||
|
|
@ -181,9 +181,11 @@ class ProjectCreate(BaseModel):
|
||||||
ssh_proxy_jump: str | None = None
|
ssh_proxy_jump: str | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@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:
|
if self.project_type == "operations" and not self.ssh_host:
|
||||||
raise ValueError("ssh_host is required for operations projects")
|
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
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue