day 1: Kin from zero to production - agents, GUI, autopilot, 352 tests
This commit is contained in:
parent
8d9facda4f
commit
8a6f280cbd
22 changed files with 1907 additions and 103 deletions
|
|
@ -105,6 +105,18 @@ def test_approve_not_found(client):
|
|||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_approve_fires_task_done_hooks(client):
|
||||
"""Ручной апрув задачи должен вызывать хуки с event='task_done'."""
|
||||
from unittest.mock import patch
|
||||
with patch("core.hooks.run_hooks") as mock_hooks:
|
||||
mock_hooks.return_value = []
|
||||
r = client.post("/api/tasks/P1-001/approve", json={})
|
||||
assert r.status_code == 200
|
||||
events_fired = [call[1].get("event") or call[0][3]
|
||||
for call in mock_hooks.call_args_list]
|
||||
assert "task_done" in events_fired
|
||||
|
||||
|
||||
def test_reject_task(client):
|
||||
from core.db import init_db
|
||||
from core import models
|
||||
|
|
@ -173,14 +185,15 @@ def test_run_not_found(client):
|
|||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_run_with_allow_write(client):
|
||||
"""POST /run with allow_write=true should be accepted."""
|
||||
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True})
|
||||
def test_run_kin_038_without_allow_write(client):
|
||||
"""Регрессионный тест KIN-038: allow_write удалён из схемы,
|
||||
эндпоинт принимает запросы с пустым телом без этого параметра."""
|
||||
r = client.post("/api/tasks/P1-001/run", json={})
|
||||
assert r.status_code == 202
|
||||
|
||||
|
||||
def test_run_with_empty_body(client):
|
||||
"""POST /run with empty JSON body should default allow_write=false."""
|
||||
"""POST /run with empty JSON body should be accepted."""
|
||||
r = client.post("/api/tasks/P1-001/run", json={})
|
||||
assert r.status_code == 202
|
||||
|
||||
|
|
@ -256,14 +269,61 @@ def test_patch_task_status_persisted(client):
|
|||
assert r.json()["status"] == "blocked"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "cancelled"])
|
||||
@pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"])
|
||||
def test_patch_task_all_valid_statuses(client, status):
|
||||
"""Все 6 допустимых статусов должны приниматься."""
|
||||
"""Все 7 допустимых статусов должны приниматься (включая decomposed)."""
|
||||
r = client.patch("/api/tasks/P1-001", json={"status": status})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == status
|
||||
|
||||
|
||||
def test_patch_task_status_decomposed(client):
|
||||
"""Регрессионный тест KIN-033: API принимает статус 'decomposed'."""
|
||||
r = client.patch("/api/tasks/P1-001", json={"status": "decomposed"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "decomposed"
|
||||
|
||||
|
||||
def test_patch_task_status_decomposed_persisted(client):
|
||||
"""После установки 'decomposed' повторный GET возвращает этот статус."""
|
||||
client.patch("/api/tasks/P1-001", json={"status": "decomposed"})
|
||||
r = client.get("/api/tasks/P1-001")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "decomposed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-033 — единый источник истины для статусов
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_api_valid_statuses_match_models():
|
||||
"""API использует models.VALID_TASK_STATUSES как единственный источник истины."""
|
||||
from core import models
|
||||
import web.api as api_module
|
||||
assert api_module.VALID_STATUSES == set(models.VALID_TASK_STATUSES)
|
||||
|
||||
|
||||
def test_cli_valid_statuses_match_models():
|
||||
"""CLI использует models.VALID_TASK_STATUSES как единственный источник истины."""
|
||||
from core import models
|
||||
from cli.main import task_update
|
||||
status_param = next(p for p in task_update.params if p.name == "status")
|
||||
cli_choices = set(status_param.type.choices)
|
||||
assert cli_choices == set(models.VALID_TASK_STATUSES)
|
||||
|
||||
|
||||
def test_cli_and_api_statuses_are_identical():
|
||||
"""Список статусов в CLI и API идентичен."""
|
||||
from core import models
|
||||
import web.api as api_module
|
||||
from cli.main import task_update
|
||||
status_param = next(p for p in task_update.params if p.name == "status")
|
||||
cli_choices = set(status_param.type.choices)
|
||||
assert cli_choices == api_module.VALID_STATUSES
|
||||
assert "decomposed" in cli_choices
|
||||
assert "decomposed" in api_module.VALID_STATUSES
|
||||
|
||||
|
||||
def test_patch_task_invalid_status(client):
|
||||
"""Недопустимый статус → 400."""
|
||||
r = client.patch("/api/tasks/P1-001", json={"status": "flying"})
|
||||
|
|
@ -274,3 +334,258 @@ def test_patch_task_not_found(client):
|
|||
"""Несуществующая задача → 404."""
|
||||
r = client.patch("/api/tasks/NOPE-999", json={"status": "done"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_patch_task_empty_body_returns_400(client):
|
||||
"""PATCH с пустым телом (нет status и нет execution_mode) → 400."""
|
||||
r = client.patch("/api/tasks/P1-001", json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-022 — blocked_reason: регрессионные тесты
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_blocked_reason_saved_and_returned(client):
|
||||
"""При переходе в blocked с blocked_reason поле сохраняется и отдаётся в GET."""
|
||||
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="Step 1/2 (debugger) failed")
|
||||
conn.close()
|
||||
|
||||
r = client.get("/api/tasks/P1-001")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "blocked"
|
||||
assert data["blocked_reason"] == "Step 1/2 (debugger) failed"
|
||||
|
||||
|
||||
def test_blocked_reason_present_in_full(client):
|
||||
"""blocked_reason также присутствует в /full эндпоинте."""
|
||||
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="tester agent crashed")
|
||||
conn.close()
|
||||
|
||||
r = client.get("/api/tasks/P1-001/full")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "blocked"
|
||||
assert data["blocked_reason"] == "tester agent crashed"
|
||||
|
||||
|
||||
def test_blocked_reason_none_by_default(client):
|
||||
"""Новая задача не имеет blocked_reason."""
|
||||
r = client.get("/api/tasks/P1-001")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["blocked_reason"] is None
|
||||
|
||||
|
||||
def test_blocked_without_reason_allowed(client):
|
||||
"""Переход в blocked без причины допустим (reason=None)."""
|
||||
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")
|
||||
conn.close()
|
||||
|
||||
r = client.get("/api/tasks/P1-001")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "blocked"
|
||||
assert data["blocked_reason"] is None
|
||||
|
||||
|
||||
def test_blocked_reason_cleared_on_retry(client):
|
||||
"""При повторном запуске (статус pending) blocked_reason сбрасывается."""
|
||||
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="failed once")
|
||||
models.update_task(conn, "P1-001", status="pending", blocked_reason=None)
|
||||
conn.close()
|
||||
|
||||
r = client.get("/api/tasks/P1-001")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "pending"
|
||||
assert data["blocked_reason"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-029 — DELETE /api/projects/{project_id}/decisions/{decision_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_delete_decision_ok(client):
|
||||
"""Создаём decision через POST, удаляем DELETE → 200 с телом {"deleted": id}."""
|
||||
r = client.post("/api/decisions", json={
|
||||
"project_id": "p1",
|
||||
"type": "decision",
|
||||
"title": "Use SQLite",
|
||||
"description": "Chosen for simplicity",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
decision_id = r.json()["id"]
|
||||
|
||||
r = client.delete(f"/api/projects/p1/decisions/{decision_id}")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"deleted": decision_id}
|
||||
|
||||
r = client.get("/api/decisions?project=p1")
|
||||
assert r.status_code == 200
|
||||
ids = [d["id"] for d in r.json()]
|
||||
assert decision_id not in ids
|
||||
|
||||
|
||||
def test_delete_decision_not_found(client):
|
||||
"""DELETE несуществующего decision → 404."""
|
||||
r = client.delete("/api/projects/p1/decisions/99999")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_delete_decision_wrong_project(client):
|
||||
"""DELETE decision с чужим project_id → 404 (не раскрываем существование)."""
|
||||
r = client.post("/api/decisions", json={
|
||||
"project_id": "p1",
|
||||
"type": "decision",
|
||||
"title": "Cross-project check",
|
||||
"description": "Should not be deletable from p2",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
decision_id = r.json()["id"]
|
||||
|
||||
r = client.delete(f"/api/projects/p2/decisions/{decision_id}")
|
||||
assert r.status_code == 404
|
||||
|
||||
# Decision должен остаться нетронутым
|
||||
r = client.get("/api/decisions?project=p1")
|
||||
ids = [d["id"] for d in r.json()]
|
||||
assert decision_id in ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-035 — регрессионный тест: смена статуса на cancelled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_patch_task_status_cancelled(client):
|
||||
"""Регрессионный тест KIN-035: PATCH /api/tasks/{id} с status='cancelled' → 200."""
|
||||
r = client.patch("/api/tasks/P1-001", json={"status": "cancelled"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_patch_task_status_cancelled_persisted(client):
|
||||
"""После установки 'cancelled' повторный GET возвращает этот статус."""
|
||||
client.patch("/api/tasks/P1-001", json={"status": "cancelled"})
|
||||
r = client.get("/api/tasks/P1-001")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_cancelled_in_valid_statuses():
|
||||
"""'cancelled' присутствует в VALID_TASK_STATUSES модели и в VALID_STATUSES API."""
|
||||
from core import models
|
||||
import web.api as api_module
|
||||
assert "cancelled" in models.VALID_TASK_STATUSES
|
||||
assert "cancelled" in api_module.VALID_STATUSES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-036 — регрессионный тест: --allow-write всегда в команде через web API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_run_always_includes_allow_write_when_body_false(client):
|
||||
"""Регрессионный тест KIN-036: --allow-write присутствует в команде,
|
||||
даже если allow_write=False в теле запроса.
|
||||
|
||||
Баг: условие `if body and body.allow_write` не добавляло флаг при
|
||||
allow_write=False, что приводило к блокировке агента на 300 с."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
with patch("web.api.subprocess.Popen") as mock_popen:
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 12345
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
r = client.post("/api/tasks/P1-001/run", json={"allow_write": False})
|
||||
assert r.status_code == 202
|
||||
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
assert "--allow-write" in cmd, (
|
||||
"--allow-write обязан присутствовать всегда: без него агент зависает "
|
||||
"при попытке записи, потому что stdin=DEVNULL и нет интерактивного подтверждения"
|
||||
)
|
||||
|
||||
|
||||
def test_run_always_includes_allow_write_without_body(client):
|
||||
"""Регрессионный тест KIN-036: --allow-write присутствует даже без тела запроса."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
with patch("web.api.subprocess.Popen") as mock_popen:
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 12345
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
r = client.post("/api/tasks/P1-001/run")
|
||||
assert r.status_code == 202
|
||||
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
assert "--allow-write" in cmd
|
||||
|
||||
|
||||
def test_run_sets_kin_noninteractive_env(client):
|
||||
"""Регрессионный тест KIN-036: KIN_NONINTERACTIVE=1 всегда устанавливается
|
||||
при запуске через web API, что вместе с --allow-write предотвращает зависание."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
with patch("web.api.subprocess.Popen") as mock_popen:
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 99
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
r = client.post("/api/tasks/P1-001/run")
|
||||
assert r.status_code == 202
|
||||
|
||||
call_kwargs = mock_popen.call_args[1]
|
||||
env = call_kwargs.get("env", {})
|
||||
assert env.get("KIN_NONINTERACTIVE") == "1"
|
||||
|
||||
|
||||
def test_run_sets_stdin_devnull(client):
|
||||
"""Регрессионный тест KIN-036: stdin=DEVNULL всегда устанавливается,
|
||||
что является причиной, по которой --allow-write обязателен."""
|
||||
import subprocess as _subprocess
|
||||
from unittest.mock import patch, MagicMock
|
||||
with patch("web.api.subprocess.Popen") as mock_popen:
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 42
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
r = client.post("/api/tasks/P1-001/run")
|
||||
assert r.status_code == 202
|
||||
|
||||
call_kwargs = mock_popen.call_args[1]
|
||||
assert call_kwargs.get("stdin") == _subprocess.DEVNULL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-040 — регрессионные тесты: удаление TaskRun / allow_write из схемы
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_run_kin_040_no_taskrun_class():
|
||||
"""Регрессионный тест KIN-040: класс TaskRun удалён из web/api.py.
|
||||
allow_write больше не является частью схемы эндпоинта /run."""
|
||||
import web.api as api_module
|
||||
assert not hasattr(api_module, "TaskRun"), (
|
||||
"Класс TaskRun должен быть удалён из web/api.py (KIN-040)"
|
||||
)
|
||||
|
||||
|
||||
def test_run_kin_040_allow_write_true_ignored(client):
|
||||
"""Регрессионный тест KIN-040: allow_write=True в теле игнорируется (не 422).
|
||||
Эндпоинт не имеет body-параметра, поэтому FastAPI не валидирует тело."""
|
||||
r = client.post("/api/tasks/P1-001/run", json={"allow_write": True})
|
||||
assert r.status_code == 202
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue