diff --git a/tests/test_kin_109_regression.py b/tests/test_kin_109_regression.py index 519ab4b..0049649 100644 --- a/tests/test_kin_109_regression.py +++ b/tests/test_kin_109_regression.py @@ -5,14 +5,27 @@ Root cause: Makefile test target used bare `pytest tests/` which resolves to the system pytest (Python 3.14). Tests written for Python 3.11 could fail under 3.14. Fix: Makefile test target changed to `python3.11 -m pytest tests/`. +PR fix: _detect_test_command() now returns sys.executable -m pytest for + pyproject.toml/setup.py projects (was bare 'pytest'). Coverage: (1) Makefile test target uses python3.11, not bare pytest (2) _detect_test_command still returns 'make test' for projects with Makefile +(3) _detect_test_command returns sys.executable -m pytest for pyproject.toml/setup.py +(4) Pipeline auto-test uses project.test_command when explicitly set +(5) Pipeline auto-test uses _detect_test_command when project.test_command is NULL +(6) PATCH /api/projects: test_command absent → DB field unchanged (decision #580) +(7) Regression: pipeline auto-test early return (no test framework) does not crash """ +import sys import re from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest +from core.db import init_db +from core import models # --------------------------------------------------------------------------- @@ -73,3 +86,177 @@ class TestDetectTestCommandUnchanged: assert result == "make test", ( f"kin project should detect 'make test', got {result!r}" ) + + +# --------------------------------------------------------------------------- +# (3) _detect_test_command: pyproject.toml/setup.py → sys.executable -m pytest +# --------------------------------------------------------------------------- + +class TestDetectTestCommandSysExecutable: + def test_detect_pyproject_returns_sys_executable_pytest(self, tmp_path): + """pyproject.toml project must get sys.executable -m pytest, not bare pytest.""" + from agents.runner import _detect_test_command + + (tmp_path / "pyproject.toml").write_text("[build-system]\n") + result = _detect_test_command(str(tmp_path)) + assert result == f"{sys.executable} -m pytest" + + def test_detect_setup_py_returns_sys_executable_pytest(self, tmp_path): + """setup.py project must get sys.executable -m pytest, not bare pytest.""" + from agents.runner import _detect_test_command + + (tmp_path / "setup.py").write_text("from setuptools import setup\nsetup()\n") + result = _detect_test_command(str(tmp_path)) + assert result == f"{sys.executable} -m pytest" + + def test_detect_pyproject_not_bare_pytest(self, tmp_path): + """Regression: _detect_test_command must not return bare 'pytest' for pyproject.toml.""" + from agents.runner import _detect_test_command + + (tmp_path / "pyproject.toml").write_text("[build-system]\n") + result = _detect_test_command(str(tmp_path)) + assert result != "pytest", ( + "Must not use bare 'pytest' — would resolve to wrong Python version" + ) + + +# --------------------------------------------------------------------------- +# Shared fixture for pipeline auto-test tests +# --------------------------------------------------------------------------- + +@pytest.fixture +def conn_kin109(): + c = init_db(":memory:") + models.create_project(c, "proj", "Proj", "/tmp/proj", tech_stack=["python"]) + models.create_task(c, "PROJ-001", "proj", "Fix bug", brief={"route_type": "debug"}) + yield c + c.close() + + +def _claude_mock_success(): + m = MagicMock() + m.stdout = '{"result": "done", "usage": {"total_tokens": 100}, "cost_usd": 0.001}' + m.stderr = "" + m.returncode = 0 + return m + + +# --------------------------------------------------------------------------- +# (4/5) Pipeline auto-test: project.test_command priority vs auto-detect +# --------------------------------------------------------------------------- + +class TestAutoTestCommandPriority: + @patch("agents.runner._run_project_tests") + @patch("agents.runner.subprocess.run") + def test_uses_project_test_command_when_set(self, mock_run, mock_tests, conn_kin109, tmp_path): + """When project.test_command is set, pipeline auto-test must use that command.""" + mock_run.return_value = _claude_mock_success() + mock_tests.return_value = {"success": True, "output": "ok", "returncode": 0} + + (tmp_path / "pyproject.toml").write_text("[build-system]\n") + models.update_project( + conn_kin109, "proj", + test_command="my_custom_runner", + auto_test_enabled=True, + path=str(tmp_path), + ) + + from agents.runner import run_pipeline + run_pipeline(conn_kin109, "PROJ-001", [{"role": "backend_dev", "brief": "fix it"}]) + + mock_tests.assert_called_once() + assert mock_tests.call_args.args[1] == "my_custom_runner" + + @patch("agents.runner._run_project_tests") + @patch("agents.runner.subprocess.run") + def test_uses_detect_when_test_command_null(self, mock_run, mock_tests, conn_kin109, tmp_path): + """When project.test_command is NULL, pipeline auto-test uses _detect_test_command result.""" + mock_run.return_value = _claude_mock_success() + mock_tests.return_value = {"success": True, "output": "ok", "returncode": 0} + + (tmp_path / "pyproject.toml").write_text("[build-system]\n") + models.update_project( + conn_kin109, "proj", + test_command=None, + auto_test_enabled=True, + path=str(tmp_path), + ) + + from agents.runner import run_pipeline + run_pipeline(conn_kin109, "PROJ-001", [{"role": "backend_dev", "brief": "fix it"}]) + + mock_tests.assert_called_once() + assert mock_tests.call_args.args[1] == f"{sys.executable} -m pytest" + + +# --------------------------------------------------------------------------- +# (6) PATCH /api/projects: test_command absent → DB field unchanged (decision #580) +# --------------------------------------------------------------------------- + +class TestPatchProjectTestCommand: + @pytest.fixture + def client(self, tmp_path): + import web.api as api_module + api_module.DB_PATH = tmp_path / "test.db" + from fastapi.testclient import TestClient + from web.api import app + c = TestClient(app) + c.post("/api/projects", json={"id": "px", "name": "PX", "path": "/px"}) + # Pre-set a test_command value + c.patch("/api/projects/px", json={"test_command": "existing_runner"}) + return c + + def test_patch_without_test_command_does_not_change_db(self, client): + """PATCH without test_command field must NOT overwrite the existing value.""" + r = client.patch("/api/projects/px", json={"auto_test_enabled": False}) + assert r.status_code == 200 + + r2 = client.get("/api/projects/px") + assert r2.status_code == 200 + assert r2.json()["test_command"] == "existing_runner" + + def test_patch_with_test_command_updates_db(self, client): + """PATCH with test_command sets the new value in DB.""" + r = client.patch("/api/projects/px", json={"test_command": "new_runner"}) + assert r.status_code == 200 + + r2 = client.get("/api/projects/px") + assert r2.json()["test_command"] == "new_runner" + + def test_patch_with_empty_test_command_stores_null(self, client): + """PATCH with test_command='' clears it to NULL (enables auto-detect).""" + r = client.patch("/api/projects/px", json={"test_command": ""}) + assert r.status_code == 200 + + r2 = client.get("/api/projects/px") + assert r2.json()["test_command"] is None + + +# --------------------------------------------------------------------------- +# (7) Regression: auto-test early return (no framework detected) does not crash +# --------------------------------------------------------------------------- + +class TestAutoTestEarlyReturn: + @patch("agents.runner.subprocess.run") + def test_pipeline_skips_auto_test_when_no_framework_detected(self, mock_run, conn_kin109, tmp_path): + """When no test framework is detected and test_command is NULL, pipeline must + skip auto-test and return success — not crash.""" + mock_run.return_value = _claude_mock_success() + + # tmp_path has no Makefile, pyproject.toml, package.json, setup.py, tsconfig.json + # → _detect_test_command returns None → auto-test skipped + models.update_project( + conn_kin109, "proj", + test_command=None, + auto_test_enabled=True, + path=str(tmp_path), + ) + + from agents.runner import run_pipeline + result = run_pipeline(conn_kin109, "PROJ-001", [{"role": "backend_dev", "brief": "fix"}]) + + assert isinstance(result, dict) + assert result["success"] is True + # The skipped auto-test step must be in results + skipped = [r for r in result["results"] if r.get("_skipped") and r.get("_project_test")] + assert len(skipped) == 1 diff --git a/tests/test_kin_110_regression.py b/tests/test_kin_110_regression.py new file mode 100644 index 0000000..c1d9cc2 --- /dev/null +++ b/tests/test_kin_110_regression.py @@ -0,0 +1,162 @@ +"""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