142 lines
5.5 KiB
Python
142 lines
5.5 KiB
Python
|
|
"""Tests for core/followup.py — follow-up task generation."""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import pytest
|
||
|
|
from unittest.mock import patch, MagicMock
|
||
|
|
|
||
|
|
from core.db import init_db
|
||
|
|
from core import models
|
||
|
|
from core.followup import generate_followups, _collect_pipeline_output, _next_task_id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def conn():
|
||
|
|
c = init_db(":memory:")
|
||
|
|
models.create_project(c, "vdol", "ВДОЛЬ", "~/projects/vdolipoperek",
|
||
|
|
tech_stack=["vue3"], language="ru")
|
||
|
|
models.create_task(c, "VDOL-001", "vdol", "Security audit",
|
||
|
|
status="done", brief={"route_type": "security_audit"})
|
||
|
|
# Add some pipeline logs
|
||
|
|
models.log_agent_run(c, "vdol", "security", "execute",
|
||
|
|
task_id="VDOL-001",
|
||
|
|
output_summary=json.dumps({
|
||
|
|
"summary": "8 уязвимостей найдено",
|
||
|
|
"findings": [
|
||
|
|
{"severity": "HIGH", "title": "Admin endpoint без auth",
|
||
|
|
"file": "index.js", "line": 42},
|
||
|
|
{"severity": "HIGH", "title": "SEO endpoints без auth",
|
||
|
|
"file": "index.js", "line": 88},
|
||
|
|
{"severity": "MEDIUM", "title": "Нет rate limiting на login",
|
||
|
|
"file": "auth.js", "line": 15},
|
||
|
|
],
|
||
|
|
}, ensure_ascii=False),
|
||
|
|
success=True)
|
||
|
|
yield c
|
||
|
|
c.close()
|
||
|
|
|
||
|
|
|
||
|
|
class TestCollectPipelineOutput:
|
||
|
|
def test_collects_all_steps(self, conn):
|
||
|
|
output = _collect_pipeline_output(conn, "VDOL-001")
|
||
|
|
assert "security" in output
|
||
|
|
assert "Admin endpoint" in output
|
||
|
|
|
||
|
|
def test_empty_for_no_logs(self, conn):
|
||
|
|
assert _collect_pipeline_output(conn, "NONEXISTENT") == ""
|
||
|
|
|
||
|
|
|
||
|
|
class TestNextTaskId:
|
||
|
|
def test_increments(self, conn):
|
||
|
|
assert _next_task_id(conn, "vdol") == "VDOL-002"
|
||
|
|
|
||
|
|
def test_handles_obs_ids(self, conn):
|
||
|
|
# OBS tasks shouldn't interfere with numbering
|
||
|
|
models.create_task(conn, "VDOL-OBS-001", "vdol", "Obsidian task")
|
||
|
|
assert _next_task_id(conn, "vdol") == "VDOL-002"
|
||
|
|
|
||
|
|
|
||
|
|
class TestGenerateFollowups:
|
||
|
|
@patch("agents.runner._run_claude")
|
||
|
|
def test_creates_followup_tasks(self, mock_claude, conn):
|
||
|
|
mock_claude.return_value = {
|
||
|
|
"output": json.dumps([
|
||
|
|
{"title": "Fix admin auth", "type": "hotfix", "priority": 2,
|
||
|
|
"brief": "Add requireAuth to admin endpoints"},
|
||
|
|
{"title": "Add rate limiting", "type": "feature", "priority": 4,
|
||
|
|
"brief": "Rate limit login to 5/15min"},
|
||
|
|
]),
|
||
|
|
"returncode": 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
created = generate_followups(conn, "VDOL-001")
|
||
|
|
|
||
|
|
assert len(created) == 2
|
||
|
|
assert created[0]["id"] == "VDOL-002"
|
||
|
|
assert created[1]["id"] == "VDOL-003"
|
||
|
|
assert created[0]["title"] == "Fix admin auth"
|
||
|
|
assert created[0]["parent_task_id"] == "VDOL-001"
|
||
|
|
assert created[0]["priority"] == 2
|
||
|
|
assert created[1]["parent_task_id"] == "VDOL-001"
|
||
|
|
|
||
|
|
# Brief should contain source reference
|
||
|
|
assert created[0]["brief"]["source"] == "followup:VDOL-001"
|
||
|
|
assert created[0]["brief"]["route_type"] == "hotfix"
|
||
|
|
|
||
|
|
@patch("agents.runner._run_claude")
|
||
|
|
def test_handles_empty_response(self, mock_claude, conn):
|
||
|
|
mock_claude.return_value = {"output": "[]", "returncode": 0}
|
||
|
|
assert generate_followups(conn, "VDOL-001") == []
|
||
|
|
|
||
|
|
@patch("agents.runner._run_claude")
|
||
|
|
def test_handles_wrapped_response(self, mock_claude, conn):
|
||
|
|
"""PM might return {tasks: [...]} instead of bare array."""
|
||
|
|
mock_claude.return_value = {
|
||
|
|
"output": json.dumps({"tasks": [
|
||
|
|
{"title": "Fix X", "priority": 3},
|
||
|
|
]}),
|
||
|
|
"returncode": 0,
|
||
|
|
}
|
||
|
|
created = generate_followups(conn, "VDOL-001")
|
||
|
|
assert len(created) == 1
|
||
|
|
|
||
|
|
@patch("agents.runner._run_claude")
|
||
|
|
def test_handles_invalid_json(self, mock_claude, conn):
|
||
|
|
mock_claude.return_value = {"output": "not json", "returncode": 0}
|
||
|
|
assert generate_followups(conn, "VDOL-001") == []
|
||
|
|
|
||
|
|
def test_no_logs_returns_empty(self, conn):
|
||
|
|
models.create_task(conn, "VDOL-999", "vdol", "Empty task")
|
||
|
|
assert generate_followups(conn, "VDOL-999") == []
|
||
|
|
|
||
|
|
def test_nonexistent_task(self, conn):
|
||
|
|
assert generate_followups(conn, "NOPE") == []
|
||
|
|
|
||
|
|
def test_dry_run(self, conn):
|
||
|
|
result = generate_followups(conn, "VDOL-001", dry_run=True)
|
||
|
|
assert len(result) == 1
|
||
|
|
assert result[0]["_dry_run"] is True
|
||
|
|
assert "followup" in result[0]["_prompt"].lower() or "Previous step output" in result[0]["_prompt"]
|
||
|
|
|
||
|
|
@patch("agents.runner._run_claude")
|
||
|
|
def test_logs_generation(self, mock_claude, conn):
|
||
|
|
mock_claude.return_value = {
|
||
|
|
"output": json.dumps([{"title": "Fix A", "priority": 2}]),
|
||
|
|
"returncode": 0,
|
||
|
|
}
|
||
|
|
generate_followups(conn, "VDOL-001")
|
||
|
|
|
||
|
|
logs = conn.execute(
|
||
|
|
"SELECT * FROM agent_logs WHERE agent_role='followup_pm'"
|
||
|
|
).fetchall()
|
||
|
|
assert len(logs) == 1
|
||
|
|
assert logs[0]["task_id"] == "VDOL-001"
|
||
|
|
|
||
|
|
@patch("agents.runner._run_claude")
|
||
|
|
def test_prompt_includes_language(self, mock_claude, conn):
|
||
|
|
"""Followup prompt should include language instruction."""
|
||
|
|
mock_claude.return_value = {"output": "[]", "returncode": 0}
|
||
|
|
generate_followups(conn, "VDOL-001")
|
||
|
|
|
||
|
|
prompt = mock_claude.call_args[0][0]
|
||
|
|
assert "Russian" in prompt
|