"""Regression tests for KIN-110 — Followup button for blocked tasks. Verifies: 1. POST /api/tasks/{id}/followup endpoint exists and responds for blocked task 2. Endpoint returns 404 for non-existent task 3. Endpoint works for any task status (not just blocked) 4. Response contains expected fields: created, pending_actions, needs_decision """ import json import pytest from unittest.mock import patch, MagicMock 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 from fastapi.testclient import TestClient c = TestClient(app) c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) c.post("/api/tasks", json={"project_id": "p1", "title": "Fix auth"}) return c @pytest.fixture def blocked_client(tmp_path): """Client with a blocked task seeded.""" db_path = tmp_path / "test.db" api_module.DB_PATH = db_path from web.api import app from fastapi.testclient import TestClient from core.db import init_db from core import models c = TestClient(app) c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) c.post("/api/tasks", json={"project_id": "p1", "title": "Blocked task"}) # Set task to blocked status conn = init_db(db_path) models.update_task(conn, "P1-001", status="blocked") conn.close() return c class TestFollowupEndpoint: """Tests for POST /api/tasks/{id}/followup endpoint.""" @patch("agents.runner._run_claude") def test_followup_blocked_task_returns_200(self, mock_claude, blocked_client): """POST /followup на blocked-задаче должен вернуть 200.""" mock_claude.return_value = { "output": json.dumps([ {"title": "Fix dependency", "type": "hotfix", "priority": 2, "brief": "Add missing dep"}, ]), "returncode": 0, } r = blocked_client.post("/api/tasks/P1-001/followup", json={}) assert r.status_code == 200 @patch("agents.runner._run_claude") def test_followup_returns_created_tasks(self, mock_claude, blocked_client): """Ответ /followup содержит список created с созданными задачами.""" mock_claude.return_value = { "output": json.dumps([ {"title": "Fix dependency", "type": "hotfix", "priority": 2, "brief": "Add missing dep"}, ]), "returncode": 0, } r = blocked_client.post("/api/tasks/P1-001/followup", json={}) assert r.status_code == 200 data = r.json() assert "created" in data assert len(data["created"]) == 1 assert data["created"][0]["title"] == "Fix dependency" @patch("agents.runner._run_claude") def test_followup_response_has_required_fields(self, mock_claude, blocked_client): """Ответ /followup содержит поля created, pending_actions, needs_decision.""" mock_claude.return_value = {"output": "[]", "returncode": 0} r = blocked_client.post("/api/tasks/P1-001/followup", json={}) assert r.status_code == 200 data = r.json() assert "created" in data assert "pending_actions" in data assert "needs_decision" in data def test_followup_nonexistent_task_returns_404(self, blocked_client): """POST /followup на несуществующую задачу → 404.""" r = blocked_client.post("/api/tasks/NOPE/followup", json={}) assert r.status_code == 404 @patch("agents.runner._run_claude") def test_followup_dry_run_does_not_create_tasks(self, mock_claude, blocked_client): """dry_run=true не создаёт задачи в БД.""" from core.db import init_db from core import models mock_claude.return_value = { "output": json.dumps([ {"title": "Dry run task", "type": "hotfix", "priority": 3, "brief": "should not be created"}, ]), "returncode": 0, } r = blocked_client.post("/api/tasks/P1-001/followup", json={"dry_run": True}) assert r.status_code == 200 # No tasks should be created in DB (only original P1-001 remains) conn = init_db(api_module.DB_PATH) tasks = models.list_tasks(conn, project_id="p1") conn.close() assert len(tasks) == 1 # Only P1-001, no new tasks @patch("agents.runner._run_claude") def test_followup_creates_child_tasks_in_db(self, mock_claude, blocked_client): """Созданные followup-задачи сохраняются в БД с parent_task_id.""" from core.db import init_db from core import models mock_claude.return_value = { "output": json.dumps([ {"title": "New dep task", "type": "feature", "priority": 4, "brief": "Install dependency"}, ]), "returncode": 0, } r = blocked_client.post("/api/tasks/P1-001/followup", json={}) assert r.status_code == 200 conn = init_db(api_module.DB_PATH) tasks = models.list_tasks(conn, project_id="p1") conn.close() # Original task + 1 followup assert len(tasks) == 2 followup = next((t for t in tasks if t["id"] != "P1-001"), None) assert followup is not None assert followup["parent_task_id"] == "P1-001" @patch("agents.runner._run_claude") def test_followup_pending_actions_for_permission_blocked_items( self, mock_claude, blocked_client ): """Элементы с permission-блокером попадают в pending_actions, не в created.""" mock_claude.return_value = { "output": json.dumps([ {"title": "Normal task", "type": "hotfix", "priority": 2, "brief": "Just a fix"}, {"title": "Ручное применение .env", "type": "hotfix", "priority": 3, "brief": "Не получили разрешение на запись"}, ]), "returncode": 0, } r = blocked_client.post("/api/tasks/P1-001/followup", json={}) assert r.status_code == 200 data = r.json() assert len(data["created"]) == 1 assert len(data["pending_actions"]) == 1 assert data["needs_decision"] is True