diff --git a/tests/test_api.py b/tests/test_api.py index ee72994..9a212e4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1265,3 +1265,71 @@ def test_kin016_pipeline_blocked_agent_stops_next_steps_integration(client): assert items[0]["task_id"] == "P1-001" assert items[0]["reason"] == "no repo access" assert items[0]["agent_role"] == "debugger" + + + +# --------------------------------------------------------------------------- +# KIN-071: project_type и SSH-поля в API +# --------------------------------------------------------------------------- + +def test_create_operations_project_with_ssh_fields(client): + """KIN-071: POST /api/projects с project_type=operations и SSH-полями возвращает 200.""" + r = client.post("/api/projects", json={ + "id": "srv1", + "name": "My Server", + "path": "", + "project_type": "operations", + "ssh_host": "10.0.0.1", + "ssh_user": "root", + "ssh_key_path": "~/.ssh/id_rsa", + "ssh_proxy_jump": "jumpt", + }) + assert r.status_code == 200 + data = r.json() + assert data["project_type"] == "operations" + assert data["ssh_host"] == "10.0.0.1" + assert data["ssh_user"] == "root" + assert data["ssh_key_path"] == "~/.ssh/id_rsa" + assert data["ssh_proxy_jump"] == "jumpt" + + +def test_create_project_invalid_type_returns_400(client): + """KIN-071: POST /api/projects с недопустимым project_type → 400.""" + r = client.post("/api/projects", json={ + "id": "bad", + "name": "Bad", + "path": "/bad", + "project_type": "legacy", + }) + assert r.status_code == 400 + + +def test_patch_project_invalid_type_returns_400(client): + """KIN-071: PATCH /api/projects/{id} с недопустимым project_type → 400.""" + r = client.patch("/api/projects/p1", json={"project_type": "invalid_type"}) + assert r.status_code == 400 + + +def test_create_operations_project_without_ssh_host_allowed(client): + """KIN-071: API не валидирует ssh_host на стороне бэкенда — проект создаётся без него.""" + r = client.post("/api/projects", json={ + "id": "srv2", + "name": "Server No SSH", + "path": "", + "project_type": "operations", + }) + assert r.status_code == 200 + assert r.json()["project_type"] == "operations" + assert r.json()["ssh_host"] is None + + +def test_create_research_project_type_accepted(client): + """KIN-071: project_type=research принимается API.""" + r = client.post("/api/projects", json={ + "id": "res1", + "name": "Research Project", + "path": "/research", + "project_type": "research", + }) + assert r.status_code == 200 + assert r.json()["project_type"] == "research" diff --git a/tests/test_context_builder.py b/tests/test_context_builder.py index cbcb274..9c6961d 100644 --- a/tests/test_context_builder.py +++ b/tests/test_context_builder.py @@ -345,3 +345,64 @@ class TestOperationsProject: ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv") prompt = format_prompt(ctx, "sysadmin", "You are sysadmin.") assert "Project type: operations" in prompt + + +# --------------------------------------------------------------------------- +# KIN-071: PM routing — operations project routes PM to infra_* pipelines +# --------------------------------------------------------------------------- + +class TestPMRoutingOperations: + """PM-контекст для operations-проекта должен содержать infra-маршруты, + не включающие architect/frontend_dev.""" + + @pytest.fixture + def ops_conn(self): + c = init_db(":memory:") + models.create_project( + c, "srv", "My Server", "", + project_type="operations", + ssh_host="10.0.0.1", + ssh_user="root", + ) + models.create_task(c, "SRV-001", "srv", "Scan server") + yield c + c.close() + + def test_pm_context_has_operations_project_type(self, ops_conn): + """PM получает project_type=operations в контексте проекта.""" + ctx = build_context(ops_conn, "SRV-001", "pm", "srv") + assert ctx["project"]["project_type"] == "operations" + + def test_pm_context_has_infra_scan_route(self, ops_conn): + """PM-контекст содержит маршрут infra_scan из specialists.yaml.""" + ctx = build_context(ops_conn, "SRV-001", "pm", "srv") + assert "infra_scan" in ctx["routes"] + + def test_pm_context_has_infra_debug_route(self, ops_conn): + """PM-контекст содержит маршрут infra_debug из specialists.yaml.""" + ctx = build_context(ops_conn, "SRV-001", "pm", "srv") + assert "infra_debug" in ctx["routes"] + + def test_infra_scan_route_uses_sysadmin(self, ops_conn): + """infra_scan маршрут включает sysadmin в шагах.""" + ctx = build_context(ops_conn, "SRV-001", "pm", "srv") + steps = ctx["routes"]["infra_scan"]["steps"] + assert "sysadmin" in steps + + def test_infra_scan_route_excludes_architect(self, ops_conn): + """infra_scan маршрут не назначает architect.""" + ctx = build_context(ops_conn, "SRV-001", "pm", "srv") + steps = ctx["routes"]["infra_scan"]["steps"] + assert "architect" not in steps + + def test_infra_scan_route_excludes_frontend_dev(self, ops_conn): + """infra_scan маршрут не назначает frontend_dev.""" + ctx = build_context(ops_conn, "SRV-001", "pm", "srv") + steps = ctx["routes"]["infra_scan"]["steps"] + assert "frontend_dev" not in steps + + def test_format_prompt_pm_operations_project_type_label(self, ops_conn): + """format_prompt для PM с operations-проектом содержит 'Project type: operations'.""" + ctx = build_context(ops_conn, "SRV-001", "pm", "srv") + prompt = format_prompt(ctx, "pm", "You are PM.") + assert "Project type: operations" in prompt diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..ebc63bb --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,132 @@ +"""Tests for core/db.py — schema and migration (KIN-071).""" + +import sqlite3 +import pytest +from core.db import init_db, _migrate + + +@pytest.fixture +def conn(): + c = init_db(db_path=":memory:") + yield c + c.close() + + +def _cols(conn, table: str) -> set[str]: + """Return set of column names for a table.""" + return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + + +# --------------------------------------------------------------------------- +# Schema: новые колонки KIN-071 присутствуют при свежей инициализации +# --------------------------------------------------------------------------- + +class TestProjectsSchemaKin071: + """PRAGMA table_info(projects) должен содержать новые KIN-071 колонки.""" + + def test_schema_has_project_type_column(self, conn): + assert "project_type" in _cols(conn, "projects") + + def test_schema_has_ssh_host_column(self, conn): + assert "ssh_host" in _cols(conn, "projects") + + def test_schema_has_ssh_user_column(self, conn): + assert "ssh_user" in _cols(conn, "projects") + + def test_schema_has_ssh_key_path_column(self, conn): + assert "ssh_key_path" in _cols(conn, "projects") + + def test_schema_has_ssh_proxy_jump_column(self, conn): + assert "ssh_proxy_jump" in _cols(conn, "projects") + + def test_schema_has_description_column(self, conn): + assert "description" in _cols(conn, "projects") + + def test_project_type_defaults_to_development(self, conn): + """INSERT без project_type → значение по умолчанию 'development'.""" + conn.execute( + "INSERT INTO projects (id, name, path) VALUES ('t1', 'T', '/t')" + ) + conn.commit() + row = conn.execute( + "SELECT project_type FROM projects WHERE id='t1'" + ).fetchone() + assert row["project_type"] == "development" + + def test_ssh_fields_default_to_null(self, conn): + """SSH-поля по умолчанию NULL.""" + conn.execute( + "INSERT INTO projects (id, name, path) VALUES ('t2', 'T', '/t')" + ) + conn.commit() + row = conn.execute( + "SELECT ssh_host, ssh_user, ssh_key_path, ssh_proxy_jump FROM projects WHERE id='t2'" + ).fetchone() + assert row["ssh_host"] is None + assert row["ssh_user"] is None + assert row["ssh_key_path"] is None + assert row["ssh_proxy_jump"] is None + + +# --------------------------------------------------------------------------- +# Migration: _migrate добавляет KIN-071 колонки в старую схему (без них) +# --------------------------------------------------------------------------- + +def _old_schema_conn() -> sqlite3.Connection: + """Создаёт соединение с минимальной 'старой' схемой без KIN-071 колонок.""" + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.executescript(""" + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL, + status TEXT DEFAULT 'active', + language TEXT DEFAULT 'ru', + execution_mode TEXT NOT NULL DEFAULT 'review' + ); + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT DEFAULT 'pending', + execution_mode TEXT + ); + """) + conn.commit() + return conn + + +def test_migrate_adds_project_type_to_old_schema(): + """_migrate добавляет project_type в старую схему без этой колонки.""" + conn = _old_schema_conn() + _migrate(conn) + assert "project_type" in _cols(conn, "projects") + conn.close() + + +def test_migrate_adds_ssh_host_to_old_schema(): + """_migrate добавляет ssh_host в старую схему.""" + conn = _old_schema_conn() + _migrate(conn) + assert "ssh_host" in _cols(conn, "projects") + conn.close() + + +def test_migrate_adds_all_ssh_columns_to_old_schema(): + """_migrate добавляет все SSH-колонки разом в старую схему.""" + conn = _old_schema_conn() + _migrate(conn) + cols = _cols(conn, "projects") + assert {"ssh_host", "ssh_user", "ssh_key_path", "ssh_proxy_jump", "description"}.issubset(cols) + conn.close() + + +def test_migrate_is_idempotent(): + """Повторный вызов _migrate не ломает схему.""" + conn = init_db(":memory:") + before = _cols(conn, "projects") + _migrate(conn) + after = _cols(conn, "projects") + assert before == after + conn.close() diff --git a/tests/test_runner.py b/tests/test_runner.py index 33f30a1..0a0c023 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1916,3 +1916,113 @@ class TestSaveSysadminOutput: from agents.runner import _save_sysadmin_output result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": ""}) assert result["decisions_added"] == 0 + + def test_full_sysadmin_output_format_saves_docker_and_systemctl_as_decisions(self, ops_conn): + """KIN-071: полный формат вывода sysadmin (docker ps + systemctl) → decisions + modules.""" + from agents.runner import _save_sysadmin_output + # Симуляция реального вывода sysadmin-агента после docker ps и systemctl + output = { + "status": "done", + "summary": "Ubuntu 22.04, nginx + postgres + app in docker", + "os": "Ubuntu 22.04 LTS, kernel 5.15.0", + "services": [ + {"name": "nginx", "type": "systemd", "status": "running", "note": "web proxy"}, + {"name": "myapp", "type": "docker", "image": "myapp:1.2.3", "ports": ["80:8080"]}, + {"name": "postgres", "type": "docker", "image": "postgres:15", "ports": ["5432:5432"]}, + ], + "open_ports": [ + {"port": 80, "proto": "tcp", "process": "nginx"}, + {"port": 5432, "proto": "tcp", "process": "postgres"}, + ], + "decisions": [ + { + "type": "gotcha", + "title": "nginx proxies to docker app on 8080", + "description": "nginx.conf proxy_pass http://localhost:8080", + "tags": ["nginx", "docker"], + }, + { + "type": "decision", + "title": "postgres data on /var/lib/postgresql", + "description": "Volume mount /var/lib/postgresql/data persists DB", + "tags": ["postgres", "storage"], + }, + ], + "modules": [ + { + "name": "nginx", + "type": "service", + "path": "/etc/nginx", + "description": "Reverse proxy", + "owner_role": "sysadmin", + }, + { + "name": "myapp", + "type": "docker", + "path": "/opt/myapp", + "description": "Main application container", + }, + { + "name": "postgres", + "type": "docker", + "path": "/var/lib/postgresql", + "description": "Database", + }, + ], + } + result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)}) + + assert result["decisions_added"] == 2 + assert result["modules_added"] == 3 + + decisions = models.get_decisions(ops_conn, "srv") + d_titles = {d["title"] for d in decisions} + assert "nginx proxies to docker app on 8080" in d_titles + assert "postgres data on /var/lib/postgresql" in d_titles + + modules = models.get_modules(ops_conn, "srv") + m_names = {m["name"] for m in modules} + assert {"nginx", "myapp", "postgres"} == m_names + + def test_invalid_decision_type_normalized_to_decision(self, ops_conn): + """KIN-071: тип 'workaround' не входит в VALID_DECISION_TYPES → нормализуется в 'decision'.""" + from agents.runner import _save_sysadmin_output + output = { + "decisions": [ + { + "type": "workaround", + "title": "Use /proc/net for port list", + "description": "ss not installed, fallback to /proc/net/tcp", + "tags": ["networking"], + }, + ], + "modules": [], + } + _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)}) + decisions = models.get_decisions(ops_conn, "srv") + assert len(decisions) == 1 + assert decisions[0]["type"] == "decision" + + def test_decision_missing_title_skipped(self, ops_conn): + """KIN-071: decision без title пропускается.""" + from agents.runner import _save_sysadmin_output + output = { + "decisions": [ + {"type": "gotcha", "title": "", "description": "Something"}, + ], + "modules": [], + } + result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)}) + assert result["decisions_added"] == 0 + + def test_module_missing_name_skipped(self, ops_conn): + """KIN-071: module без name пропускается.""" + from agents.runner import _save_sysadmin_output + output = { + "decisions": [], + "modules": [ + {"name": "", "type": "service", "path": "/etc/something"}, + ], + } + result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)}) + assert result["modules_added"] == 0