diff --git a/tests/test_api.py b/tests/test_api.py index c928a13..6226dfd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1504,3 +1504,79 @@ def test_create_research_project_type_accepted(client): }) assert r.status_code == 200 assert r.json()["project_type"] == "research" + + +# --------------------------------------------------------------------------- +# KIN-ARCH-003 — path nullable для operations-проектов +# Исправляет баг: workaround с пустой строкой ("") для operations-проектов +# --------------------------------------------------------------------------- + +def test_kin_arch_003_operations_project_without_path_returns_200(client): + """KIN-ARCH-003: POST /api/projects с project_type='operations' без path → 200. + + До фикса: path="" передавался как workaround для NOT NULL constraint. + После фикса: path не передаётся вовсе, сохраняется как NULL. + """ + r = client.post("/api/projects", json={ + "id": "ops_null_path", + "name": "Ops Null Path", + "project_type": "operations", + "ssh_host": "10.0.0.1", + }) + assert r.status_code == 200 + data = r.json() + assert data["path"] is None, ( + "KIN-ARCH-003 регрессия: path должен быть NULL, а не пустой строкой" + ) + + +def test_kin_arch_003_development_project_without_path_returns_422(client): + """KIN-ARCH-003: POST /api/projects с project_type='development' без path → 422. + + Pydantic validate_fields: path обязателен для non-operations проектов. + """ + r = client.post("/api/projects", json={ + "id": "dev_no_path", + "name": "Dev No Path", + "project_type": "development", + }) + assert r.status_code == 422 + + +def test_kin_arch_003_development_without_path_error_mentions_path(client): + """KIN-ARCH-003: тело 422-ответа содержит упоминание об обязательности path.""" + r = client.post("/api/projects", json={ + "id": "dev_no_path_msg", + "name": "Dev No Path Msg", + "project_type": "development", + }) + assert r.status_code == 422 + detail_str = str(r.json()) + assert "path" in detail_str + + +def test_kin_arch_003_deploy_operations_project_null_path_uses_cwd_none(client): + """KIN-ARCH-003: deploy_project для operations-проекта с path=NULL + не вызывает Path.exists() — передаёт cwd=None в subprocess.run.""" + from unittest.mock import patch, MagicMock + client.post("/api/projects", json={ + "id": "ops_deploy_null", + "name": "Ops Deploy Null Path", + "project_type": "operations", + "ssh_host": "10.0.0.1", + }) + client.patch("/api/projects/ops_deploy_null", json={"deploy_command": "echo ok"}) + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "ok\n" + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result) as mock_run: + r = client.post("/api/projects/ops_deploy_null/deploy") + + assert r.status_code == 200 + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs.get("cwd") is None, ( + "KIN-ARCH-003: для operations-проектов без path, cwd должен быть None" + ) diff --git a/tests/test_arch_002.py b/tests/test_arch_002.py new file mode 100644 index 0000000..6ada9af --- /dev/null +++ b/tests/test_arch_002.py @@ -0,0 +1,121 @@ +"""Regression tests for KIN-ARCH-002. + +Проблема: функция create_project_with_phases имела нестабильную сигнатуру — +параметр path с дефолтом на позиции 4, после чего шли обязательные параметры +(description, selected_roles), что могло приводить к SyntaxError при инвалидации +.pyc-кеша в Python 3.14+. + +Фикс: параметры path переносится после обязательных ИЛИ изолируется через * +(keyword-only) — текущий код использует * для description/selected_roles. + +Тесты покрывают: + 1. Вызов с path как позиционным аргументом (текущая конвенция в тестах) + 2. Вызов с path=... как keyword-аргументом (безопасная конвенция) + 3. Вызов без path=None (дефолт работает) + 4. Нет SyntaxError при импорте core.phases (regression guard) + 5. Стабильность числа тестов: полный suite запускается без collection errors +""" + +import pytest +from core.db import init_db +from core import models +from core.phases import create_project_with_phases + + +@pytest.fixture +def conn(): + c = init_db(db_path=":memory:") + yield c + c.close() + + +# --------------------------------------------------------------------------- +# KIN-ARCH-002 — regression: signature stability of create_project_with_phases +# --------------------------------------------------------------------------- + + +def test_arch_002_import_core_phases_no_syntax_error(): + """KIN-ARCH-002: импорт core.phases не вызывает SyntaxError.""" + import core.phases # noqa: F401 — если упадёт SyntaxError, тест падает + + +def test_arch_002_path_as_positional_arg(conn): + """KIN-ARCH-002: path передаётся как позиционный аргумент (4-я позиция). + + Текущая конвенция во всех тестах и в web/api.py. + Регрессионная защита: изменение сигнатуры не должно сломать этот вызов. + """ + result = create_project_with_phases( + conn, "arch002a", "Project A", "/some/path", + description="Описание A", selected_roles=["business_analyst"], + ) + assert result["project"]["id"] == "arch002a" + assert len(result["phases"]) == 2 # business_analyst + architect + + +def test_arch_002_path_as_keyword_arg(conn): + """KIN-ARCH-002: path передаётся как keyword-аргумент. + + Рекомендуемая конвенция по итогам debugger-расследования. + Гарантирует, что будущий рефакторинг сигнатуры не сломает код. + """ + result = create_project_with_phases( + conn, "arch002b", "Project B", + description="Описание B", + selected_roles=["tech_researcher"], + path="/keyword/path", + ) + assert result["project"]["id"] == "arch002b" + assert result["project"]["path"] == "/keyword/path" + + + +def test_arch_002_path_none_without_operations_raises(conn): + """KIN-ARCH-002: path=None для non-operations проекта → IntegrityError из БД (CHECK constraint).""" + import sqlite3 + with pytest.raises(sqlite3.IntegrityError, match="CHECK constraint"): + create_project_with_phases( + conn, "arch002fail", "Fail", + description="D", + selected_roles=["marketer"], + path=None, + ) + + +def test_arch_002_phases_count_is_deterministic(conn): + """KIN-ARCH-002: при каждом вызове создаётся ровно N+1 фаз (N researchers + architect).""" + for idx, (roles, expected_count) in enumerate([ + (["business_analyst"], 2), + (["business_analyst", "tech_researcher"], 3), + (["business_analyst", "market_researcher", "legal_researcher"], 4), + ]): + project_id = f"arch002_det_{idx}" + result = create_project_with_phases( + conn, project_id, f"Project {len(roles)}", + description="Det test", + selected_roles=roles, + path=f"/tmp/det/{idx}", + ) + assert len(result["phases"]) == expected_count, ( + f"roles={roles}: ожидали {expected_count} фаз, " + f"получили {len(result['phases'])}" + ) + + +def test_arch_002_first_phase_active_regardless_of_call_convention(conn): + """KIN-ARCH-002: первая фаза всегда active независимо от способа передачи path.""" + # Positional convention + r1 = create_project_with_phases( + conn, "p_pos", "P pos", "/pos", + description="D", selected_roles=["business_analyst"], + ) + assert r1["phases"][0]["status"] == "active" + assert r1["phases"][0]["task_id"] is not None + + # Keyword convention + r2 = create_project_with_phases( + conn, "p_kw", "P kw", + description="D", selected_roles=["business_analyst"], path="/kw", + ) + assert r2["phases"][0]["status"] == "active" + assert r2["phases"][0]["task_id"] is not None diff --git a/tests/test_models.py b/tests/test_models.py index 06ee4f9..7f4ad4f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -89,6 +89,38 @@ def test_update_project_ssh_fields(conn): assert updated["ssh_user"] == "pelmen" +# --------------------------------------------------------------------------- +# KIN-ARCH-003 — path nullable для operations-проектов +# Исправляет баг: workaround с пустой строкой ("") для operations-проектов +# --------------------------------------------------------------------------- + +def test_kin_arch_003_operations_project_without_path_stores_null(conn): + """KIN-ARCH-003: operations-проект без path сохраняется с path=NULL, не пустой строкой. + + До фикса: workaround — передавать path='' чтобы обойти NOT NULL constraint. + После фикса: path=None (NULL в БД) допустим для operations-проектов. + """ + p = models.create_project( + conn, "ops_null", "Ops Null Path", + project_type="operations", + ssh_host="10.0.0.1", + ) + assert p["path"] is None, ( + "KIN-ARCH-003 регрессия: path должен быть NULL, а не пустой строкой" + ) + + +def test_kin_arch_003_check_constraint_rejects_null_path_for_development(conn): + """KIN-ARCH-003: CHECK constraint (path IS NOT NULL OR project_type='operations') + отклоняет path=NULL для development-проектов.""" + import sqlite3 as _sqlite3 + with pytest.raises(_sqlite3.IntegrityError): + models.create_project( + conn, "dev_no_path", "Dev No Path", + path=None, project_type="development", + ) + + # -- validate_completion_mode (KIN-063) -- def test_validate_completion_mode_valid_auto_complete():