kin/tests/test_api.py

929 lines
36 KiB
Python
Raw Normal View History

"""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_revise_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/revise", json={
"comment": "Доисследуй edge case с пустым массивом"
})
assert r.status_code == 200
assert r.json()["status"] == "in_progress"
# Verify task is in_progress with revise_comment stored
conn = init_db(api_module.DB_PATH)
row = conn.execute("SELECT status, revise_comment FROM tasks WHERE id = 'P1-001'").fetchone()
conn.close()
assert row["status"] == "in_progress"
assert row["revise_comment"] == "Доисследуй edge case с пустым массивом"
def test_revise_not_found(client):
r = client.post("/api/tasks/NOPE/revise", json={"comment": "fix it"})
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
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# KIN-020 — manual_escalation задачи: PATCH status='done' резолвит задачу
# ---------------------------------------------------------------------------
def test_patch_manual_escalation_task_to_done(client):
"""PATCH status='done' на manual_escalation задаче — статус обновляется — KIN-020."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.create_task(conn, "P1-002", "p1", "Fix .dockerignore manually",
brief={"task_type": "manual_escalation",
"source": "followup:P1-001",
"description": "Ручное применение .dockerignore"})
conn.close()
r = client.patch("/api/tasks/P1-002", json={"status": "done"})
assert r.status_code == 200
assert r.json()["status"] == "done"
def test_manual_escalation_task_brief_preserved_after_patch(client):
"""PATCH не затирает brief.task_type — поле manual_escalation сохраняется — KIN-020."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.create_task(conn, "P1-002", "p1", "Fix manually",
brief={"task_type": "manual_escalation",
"source": "followup:P1-001"})
conn.close()
client.patch("/api/tasks/P1-002", json={"status": "done"})
r = client.get("/api/tasks/P1-002")
assert r.status_code == 200
assert r.json()["brief"]["task_type"] == "manual_escalation"
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"
)
# ---------------------------------------------------------------------------
# KIN-065 — PATCH /api/projects/{id} — autocommit_enabled toggle
# ---------------------------------------------------------------------------
def test_patch_project_autocommit_enabled_true(client):
"""PATCH с autocommit_enabled=true → 200, поле установлено в 1."""
r = client.patch("/api/projects/p1", json={"autocommit_enabled": True})
assert r.status_code == 200
assert r.json()["autocommit_enabled"] == 1
def test_patch_project_autocommit_enabled_false(client):
"""После включения PATCH с autocommit_enabled=false → 200, поле установлено в 0."""
client.patch("/api/projects/p1", json={"autocommit_enabled": True})
r = client.patch("/api/projects/p1", json={"autocommit_enabled": False})
assert r.status_code == 200
assert r.json()["autocommit_enabled"] == 0
def test_patch_project_autocommit_persisted_via_sql(client):
"""После PATCH autocommit_enabled=True прямой SQL подтверждает значение 1."""
client.patch("/api/projects/p1", json={"autocommit_enabled": True})
from core.db import init_db
conn = init_db(api_module.DB_PATH)
row = conn.execute("SELECT autocommit_enabled FROM projects WHERE id = 'p1'").fetchone()
conn.close()
assert row is not None
assert row[0] == 1
def test_patch_project_autocommit_false_persisted_via_sql(client):
"""После PATCH autocommit_enabled=False прямой SQL подтверждает значение 0."""
client.patch("/api/projects/p1", json={"autocommit_enabled": True})
client.patch("/api/projects/p1", json={"autocommit_enabled": False})
from core.db import init_db
conn = init_db(api_module.DB_PATH)
row = conn.execute("SELECT autocommit_enabled FROM projects WHERE id = 'p1'").fetchone()
conn.close()
assert row is not None
assert row[0] == 0
def test_patch_project_autocommit_null_before_first_update(client):
"""Новый проект имеет autocommit_enabled=NULL/0 (falsy) до первого обновления."""
client.post("/api/projects", json={"id": "p_new", "name": "New", "path": "/new"})
from core.db import init_db
conn = init_db(api_module.DB_PATH)
row = conn.execute("SELECT autocommit_enabled FROM projects WHERE id = 'p_new'").fetchone()
conn.close()
assert row is not None
assert not row[0] # DEFAULT 0 или NULL — в любом случае falsy
def test_patch_project_empty_body_returns_400(client):
"""PATCH проекта без полей → 400."""
r = client.patch("/api/projects/p1", json={})
assert r.status_code == 400
def test_patch_project_not_found(client):
"""PATCH несуществующего проекта → 404."""
r = client.patch("/api/projects/NOPE", json={"autocommit_enabled": True})
assert r.status_code == 404
def test_patch_project_autocommit_and_execution_mode_together(client):
"""PATCH с autocommit_enabled и execution_mode → оба поля обновлены."""
r = client.patch("/api/projects/p1", json={
"autocommit_enabled": True,
"execution_mode": "auto_complete",
})
assert r.status_code == 200
data = r.json()
assert data["autocommit_enabled"] == 1
assert data["execution_mode"] == "auto_complete"
def test_patch_project_returns_full_project_object(client):
"""PATCH возвращает полный объект проекта с id, name и autocommit_enabled."""
r = client.patch("/api/projects/p1", json={"autocommit_enabled": True})
assert r.status_code == 200
data = r.json()
assert data["id"] == "p1"
assert data["name"] == "P1"
assert "autocommit_enabled" in data
# ---------------------------------------------------------------------------
# KIN-008 — PATCH priority и route_type задачи
# ---------------------------------------------------------------------------
def test_patch_task_priority(client):
"""PATCH priority задачи обновляет поле и возвращает задачу."""
r = client.patch("/api/tasks/P1-001", json={"priority": 3})
assert r.status_code == 200
assert r.json()["priority"] == 3
def test_patch_task_priority_persisted(client):
"""После PATCH priority повторный GET возвращает новое значение."""
client.patch("/api/tasks/P1-001", json={"priority": 7})
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
assert r.json()["priority"] == 7
def test_patch_task_priority_invalid_zero(client):
"""PATCH с priority=0 → 400."""
r = client.patch("/api/tasks/P1-001", json={"priority": 0})
assert r.status_code == 400
def test_patch_task_priority_invalid_eleven(client):
"""PATCH с priority=11 → 400."""
r = client.patch("/api/tasks/P1-001", json={"priority": 11})
assert r.status_code == 400
def test_patch_task_route_type_set(client):
"""PATCH route_type сохраняет значение в brief."""
r = client.patch("/api/tasks/P1-001", json={"route_type": "feature"})
assert r.status_code == 200
data = r.json()
assert data["brief"]["route_type"] == "feature"
def test_patch_task_route_type_all_valid(client):
"""Все допустимые route_type принимаются."""
for rt in ("debug", "feature", "refactor", "hotfix"):
r = client.patch("/api/tasks/P1-001", json={"route_type": rt})
assert r.status_code == 200, f"route_type={rt} rejected"
assert r.json()["brief"]["route_type"] == rt
def test_patch_task_route_type_invalid(client):
"""Недопустимый route_type → 400."""
r = client.patch("/api/tasks/P1-001", json={"route_type": "unknown"})
assert r.status_code == 400
def test_patch_task_route_type_clear(client):
"""PATCH route_type='' очищает поле из brief."""
client.patch("/api/tasks/P1-001", json={"route_type": "debug"})
r = client.patch("/api/tasks/P1-001", json={"route_type": ""})
assert r.status_code == 200
data = r.json()
brief = data.get("brief")
if brief:
assert "route_type" not in brief
def test_patch_task_route_type_merges_brief(client):
"""route_type сохраняется вместе с другими полями brief без перезаписи."""
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", brief={"extra": "data"})
conn.close()
r = client.patch("/api/tasks/P1-001", json={"route_type": "hotfix"})
assert r.status_code == 200
brief = r.json()["brief"]
assert brief["route_type"] == "hotfix"
assert brief["extra"] == "data"
def test_patch_task_priority_and_route_type_together(client):
"""PATCH может обновить priority и route_type одновременно."""
r = client.patch("/api/tasks/P1-001", json={"priority": 2, "route_type": "refactor"})
assert r.status_code == 200
data = r.json()
assert data["priority"] == 2
assert data["brief"]["route_type"] == "refactor"
def test_patch_task_empty_body_still_returns_400(client):
"""Пустое тело по-прежнему возвращает 400 (регрессия KIN-008)."""
r = client.patch("/api/tasks/P1-001", json={})
assert r.status_code == 400
# PATCH /api/tasks/{id} — редактирование title и brief_text (KIN-015)
def test_patch_task_title(client):
"""PATCH title обновляет заголовок задачи."""
r = client.patch("/api/tasks/P1-001", json={"title": "Новый заголовок"})
assert r.status_code == 200
assert r.json()["title"] == "Новый заголовок"
def test_patch_task_title_persisted(client):
"""PATCH title сохраняется в БД."""
client.patch("/api/tasks/P1-001", json={"title": "Персистентный заголовок"})
r = client.get("/api/tasks/P1-001")
assert r.json()["title"] == "Персистентный заголовок"
def test_patch_task_title_empty_returns_400(client):
"""Пустой title → 400."""
r = client.patch("/api/tasks/P1-001", json={"title": " "})
assert r.status_code == 400
def test_patch_task_brief_text(client):
"""PATCH brief_text сохраняется в brief.text."""
r = client.patch("/api/tasks/P1-001", json={"brief_text": "Описание задачи"})
assert r.status_code == 200
assert r.json()["brief"]["text"] == "Описание задачи"
def test_patch_task_brief_text_persisted(client):
"""PATCH brief_text сохраняется в БД."""
client.patch("/api/tasks/P1-001", json={"brief_text": "Сохранённое описание"})
r = client.get("/api/tasks/P1-001")
assert r.json()["brief"]["text"] == "Сохранённое описание"
def test_patch_task_brief_text_merges_route_type(client):
"""brief_text не перезаписывает route_type в brief."""
client.patch("/api/tasks/P1-001", json={"route_type": "feature"})
client.patch("/api/tasks/P1-001", json={"brief_text": "Описание"})
r = client.get("/api/tasks/P1-001")
brief = r.json()["brief"]
assert brief["text"] == "Описание"
assert brief["route_type"] == "feature"
def test_patch_task_title_and_brief_text_together(client):
"""PATCH может обновить title и brief_text одновременно."""
r = client.patch("/api/tasks/P1-001", json={"title": "Совместное", "brief_text": "и описание"})
assert r.status_code == 200
data = r.json()
assert data["title"] == "Совместное"
assert data["brief"]["text"] == "и описание"