"""Tests for web/api.py — new task endpoints (pipeline, approve, reject, full).""" import pytest from pathlib import Path from fastapi.testclient import TestClient # Patch DB_PATH before importing app import web.api as api_module @pytest.fixture def client(tmp_path): db_path = tmp_path / "test.db" api_module.DB_PATH = db_path from web.api import app c = TestClient(app) # Seed data c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) c.post("/api/tasks", json={"project_id": "p1", "title": "Fix bug"}) return c def test_get_task(client): r = client.get("/api/tasks/P1-001") assert r.status_code == 200 assert r.json()["title"] == "Fix bug" def test_get_task_not_found(client): r = client.get("/api/tasks/NOPE") assert r.status_code == 404 def test_task_pipeline_empty(client): r = client.get("/api/tasks/P1-001/pipeline") assert r.status_code == 200 assert r.json() == [] def test_task_pipeline_with_logs(client): # Insert agent logs directly from core.db import init_db from core import models conn = init_db(api_module.DB_PATH) models.log_agent_run(conn, "p1", "debugger", "execute", task_id="P1-001", output_summary="Found bug", tokens_used=1000, duration_seconds=5, success=True) models.log_agent_run(conn, "p1", "tester", "execute", task_id="P1-001", output_summary="Tests pass", tokens_used=500, duration_seconds=3, success=True) conn.close() r = client.get("/api/tasks/P1-001/pipeline") assert r.status_code == 200 steps = r.json() assert len(steps) == 2 assert steps[0]["agent_role"] == "debugger" assert steps[0]["output_summary"] == "Found bug" assert steps[1]["agent_role"] == "tester" def test_task_full(client): r = client.get("/api/tasks/P1-001/full") assert r.status_code == 200 data = r.json() assert data["id"] == "P1-001" assert "pipeline_steps" in data assert "related_decisions" in data def test_task_full_not_found(client): r = client.get("/api/tasks/NOPE/full") assert r.status_code == 404 def test_approve_task(client): # First set task to review from core.db import init_db from core import models conn = init_db(api_module.DB_PATH) models.update_task(conn, "P1-001", status="review") conn.close() r = client.post("/api/tasks/P1-001/approve", json={}) assert r.status_code == 200 assert r.json()["status"] == "done" # Verify task is done r = client.get("/api/tasks/P1-001") assert r.json()["status"] == "done" def test_approve_with_decision(client): r = client.post("/api/tasks/P1-001/approve", json={ "decision_title": "Use AbortController", "decision_description": "Fix race condition with AbortController", "decision_type": "decision", }) assert r.status_code == 200 assert r.json()["decision"] is not None assert r.json()["decision"]["title"] == "Use AbortController" def test_approve_not_found(client): r = client.post("/api/tasks/NOPE/approve", json={}) 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 conn = init_db(api_module.DB_PATH) models.update_task(conn, "P1-001", status="review") conn.close() r = client.post("/api/tasks/P1-001/reject", json={ "reason": "Didn't fix the root cause" }) assert r.status_code == 200 assert r.json()["status"] == "pending" # Verify task is pending with review reason r = client.get("/api/tasks/P1-001") data = r.json() assert data["status"] == "pending" assert data["review"]["rejected"] == "Didn't fix the root cause" def test_reject_not_found(client): r = client.post("/api/tasks/NOPE/reject", json={"reason": "bad"}) assert r.status_code == 404 def test_task_pipeline_not_found(client): r = client.get("/api/tasks/NOPE/pipeline") assert r.status_code == 404 def test_running_endpoint_no_pipeline(client): r = client.get("/api/tasks/P1-001/running") assert r.status_code == 200 assert r.json()["running"] is False def test_running_endpoint_with_pipeline(client): from core.db import init_db from core import models conn = init_db(api_module.DB_PATH) models.create_pipeline(conn, "P1-001", "p1", "debug", [{"role": "debugger"}]) conn.close() r = client.get("/api/tasks/P1-001/running") assert r.status_code == 200 assert r.json()["running"] is True def test_running_endpoint_not_found(client): r = client.get("/api/tasks/NOPE/running") assert r.status_code == 404 def test_run_sets_in_progress(client): """POST /run should set task to in_progress immediately.""" r = client.post("/api/tasks/P1-001/run") assert r.status_code == 202 r = client.get("/api/tasks/P1-001") assert r.json()["status"] == "in_progress" def test_run_not_found(client): r = client.post("/api/tasks/NOPE/run") assert r.status_code == 404 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 be accepted.""" r = client.post("/api/tasks/P1-001/run", json={}) assert r.status_code == 202 def test_run_without_body(client): """POST /run without body should be backwards-compatible.""" r = client.post("/api/tasks/P1-001/run") assert r.status_code == 202 def test_project_summary_includes_review(client): from core.db import init_db from core import models conn = init_db(api_module.DB_PATH) models.update_task(conn, "P1-001", status="review") conn.close() r = client.get("/api/projects") projects = r.json() assert projects[0]["review_tasks"] == 1 def test_audit_not_found(client): r = client.post("/api/projects/NOPE/audit") assert r.status_code == 404 def test_audit_apply(client): """POST /audit/apply should mark tasks as done.""" r = client.post("/api/projects/p1/audit/apply", json={"task_ids": ["P1-001"]}) assert r.status_code == 200 assert r.json()["count"] == 1 assert "P1-001" in r.json()["updated"] # Verify task is done r = client.get("/api/tasks/P1-001") assert r.json()["status"] == "done" def test_audit_apply_not_found(client): r = client.post("/api/projects/NOPE/audit/apply", json={"task_ids": ["P1-001"]}) assert r.status_code == 404 def test_audit_apply_wrong_project(client): """Tasks not belonging to the project should be skipped.""" r = client.post("/api/projects/p1/audit/apply", json={"task_ids": ["WRONG-001"]}) assert r.status_code == 200 assert r.json()["count"] == 0 # --------------------------------------------------------------------------- # PATCH /api/tasks/{task_id} — смена статуса # --------------------------------------------------------------------------- def test_patch_task_status(client): """PATCH должен обновить статус и вернуть задачу.""" r = client.patch("/api/tasks/P1-001", json={"status": "review"}) assert r.status_code == 200 data = r.json() assert data["status"] == "review" assert data["id"] == "P1-001" def test_patch_task_status_persisted(client): """После PATCH повторный GET должен возвращать новый статус.""" client.patch("/api/tasks/P1-001", json={"status": "blocked"}) r = client.get("/api/tasks/P1-001") assert r.status_code == 200 assert r.json()["status"] == "blocked" @pytest.mark.parametrize("status", ["pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled"]) def test_patch_task_all_valid_statuses(client, status): """Все 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"}) assert r.status_code == 400 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 def test_patch_task_execution_mode_auto_complete_accepted(client): """KIN-063: execution_mode='auto_complete' принимается (200).""" r = client.patch("/api/tasks/P1-001", json={"execution_mode": "auto_complete"}) assert r.status_code == 200 assert r.json()["execution_mode"] == "auto_complete" def test_patch_task_execution_mode_auto_rejected(client): """KIN-063: старое значение 'auto' должно отклоняться (400) — Decision #29.""" r = client.patch("/api/tasks/P1-001", json={"execution_mode": "auto"}) 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 # --------------------------------------------------------------------------- # KIN-058 — регрессионный тест: stderr=DEVNULL у Popen в web API # --------------------------------------------------------------------------- def test_run_sets_stderr_devnull(client): """Регрессионный тест KIN-058: stderr=DEVNULL всегда устанавливается в Popen, чтобы stderr дочернего процесса не загрязнял логи uvicorn.""" 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 = 77 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("stderr") == _subprocess.DEVNULL, ( "Регрессия KIN-058: stderr у Popen должен быть DEVNULL, " "иначе вывод агента попадает в логи uvicorn" )