kin: KIN-ARCH-003 Сделать path nullable для operations-проектов

This commit is contained in:
Gros Frumos 2026-03-16 09:52:44 +02:00
parent 39acc9cc4b
commit 295a95bc7f
3 changed files with 229 additions and 0 deletions

View file

@ -1504,3 +1504,79 @@ def test_create_research_project_type_accepted(client):
}) })
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["project_type"] == "research" 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"
)

121
tests/test_arch_002.py Normal file
View file

@ -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

View file

@ -89,6 +89,38 @@ def test_update_project_ssh_fields(conn):
assert updated["ssh_user"] == "pelmen" 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) -- # -- validate_completion_mode (KIN-063) --
def test_validate_completion_mode_valid_auto_complete(): def test_validate_completion_mode_valid_auto_complete():