diff --git a/core/db.py b/core/db.py index d14ce3c..2433d08 100644 --- a/core/db.py +++ b/core/db.py @@ -13,7 +13,7 @@ SCHEMA = """ CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - path TEXT NOT NULL, + path TEXT CHECK (path IS NOT NULL OR project_type = 'operations'), tech_stack JSON, status TEXT DEFAULT 'active', priority INTEGER DEFAULT 5, @@ -364,6 +364,48 @@ def _migrate(conn: sqlite3.Connection): """) conn.commit() + # Migrate projects.path from NOT NULL to nullable (KIN-ARCH-003) + # SQLite doesn't support ALTER COLUMN, so we recreate the table. + path_col_rows = conn.execute("PRAGMA table_info(projects)").fetchall() + path_col = next((r for r in path_col_rows if r[1] == "path"), None) + if path_col and path_col[3] == 1: # notnull == 1, migration needed + conn.executescript(""" + PRAGMA foreign_keys=OFF; + CREATE TABLE projects_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT CHECK (path IS NOT NULL OR project_type = 'operations'), + tech_stack JSON, + status TEXT DEFAULT 'active', + priority INTEGER DEFAULT 5, + pm_prompt TEXT, + claude_md_path TEXT, + forgejo_repo TEXT, + language TEXT DEFAULT 'ru', + execution_mode TEXT NOT NULL DEFAULT 'review', + deploy_command TEXT, + project_type TEXT DEFAULT 'development', + ssh_host TEXT, + ssh_user TEXT, + ssh_key_path TEXT, + ssh_proxy_jump TEXT, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + autocommit_enabled INTEGER DEFAULT 0, + obsidian_vault_path TEXT + ); + INSERT INTO projects_new + SELECT id, name, path, tech_stack, status, priority, + pm_prompt, claude_md_path, forgejo_repo, language, + execution_mode, deploy_command, project_type, + ssh_host, ssh_user, ssh_key_path, ssh_proxy_jump, + description, created_at, autocommit_enabled, obsidian_vault_path + FROM projects; + DROP TABLE projects; + ALTER TABLE projects_new RENAME TO projects; + PRAGMA foreign_keys=ON; + """) + # Rename legacy 'auto' → 'auto_complete' (KIN-063) conn.execute( "UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'" diff --git a/core/models.py b/core/models.py index 3947ed3..5ec21ee 100644 --- a/core/models.py +++ b/core/models.py @@ -63,7 +63,7 @@ def create_project( conn: sqlite3.Connection, id: str, name: str, - path: str, + path: str | None = None, tech_stack: list | None = None, status: str = "active", priority: int = 5, diff --git a/tests/test_api.py b/tests/test_api.py index 9a212e4..8f46753 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1311,16 +1311,110 @@ def test_patch_project_invalid_type_returns_400(client): def test_create_operations_project_without_ssh_host_allowed(client): - """KIN-071: API не валидирует ssh_host на стороне бэкенда — проект создаётся без него.""" + """Регрессионный тест KIN-ARCH-001: воспроизводит СЛОМАННОЕ поведение до фикса. + + До фикса: POST operations-проекта без ssh_host возвращал 200. + После фикса: должен возвращать 422 (Pydantic model_validator). + Этот тест НАМЕРЕННО проверяет, что старое поведение больше не существует. + """ r = client.post("/api/projects", json={ "id": "srv2", "name": "Server No SSH", "path": "", "project_type": "operations", }) + # Фикс KIN-ARCH-001: был 200, стал 422 + assert r.status_code == 422, ( + "Регрессия KIN-ARCH-001: POST operations-проекта без ssh_host " + "должен возвращать 422, а не 200" + ) + + +# --------------------------------------------------------------------------- +# KIN-ARCH-001 — серверная валидация ssh_host для operations-проектов +# --------------------------------------------------------------------------- + +def test_kin_arch_001_operations_without_ssh_host_returns_422(client): + """Регрессионный тест KIN-ARCH-001: POST /api/projects с project_type='operations' + и без ssh_host → 422 Unprocessable Entity.""" + r = client.post("/api/projects", json={ + "id": "ops_no_ssh", + "name": "Ops Without SSH", + "path": "", + "project_type": "operations", + }) + assert r.status_code == 422 + + +def test_kin_arch_001_operations_with_empty_ssh_host_returns_422(client): + """Регрессионный тест KIN-ARCH-001: пустая строка в ssh_host считается отсутствующим + значением → 422.""" + r = client.post("/api/projects", json={ + "id": "ops_empty_ssh", + "name": "Ops Empty SSH", + "path": "", + "project_type": "operations", + "ssh_host": "", + }) + assert r.status_code == 422 + + +def test_kin_arch_001_operations_with_valid_ssh_host_returns_200(client): + """Регрессионный тест KIN-ARCH-001: POST /api/projects с project_type='operations' + и корректным ssh_host → 200, проект создаётся.""" + r = client.post("/api/projects", json={ + "id": "ops_with_ssh", + "name": "Ops With SSH", + "path": "", + "project_type": "operations", + "ssh_host": "10.0.0.42", + }) assert r.status_code == 200 - assert r.json()["project_type"] == "operations" - assert r.json()["ssh_host"] is None + data = r.json() + assert data["project_type"] == "operations" + assert data["ssh_host"] == "10.0.0.42" + + +def test_kin_arch_001_development_without_ssh_host_allowed(client): + """Регрессионный тест KIN-ARCH-001: project_type='development' без ssh_host + должен создаваться без ошибок — валидатор срабатывает только для operations.""" + r = client.post("/api/projects", json={ + "id": "dev_no_ssh", + "name": "Dev No SSH", + "path": "/dev", + "project_type": "development", + }) + assert r.status_code == 200 + assert r.json()["project_type"] == "development" + + +def test_kin_arch_001_research_without_ssh_host_allowed(client): + """Регрессионный тест KIN-ARCH-001: project_type='research' без ssh_host + должен создаваться без ошибок.""" + r = client.post("/api/projects", json={ + "id": "res_no_ssh", + "name": "Research No SSH", + "path": "/research", + "project_type": "research", + }) + assert r.status_code == 200 + assert r.json()["project_type"] == "research" + + +def test_kin_arch_001_422_error_message_mentions_ssh_host(client): + """Регрессионный тест KIN-ARCH-001: тело 422-ответа содержит сообщение об ошибке + с упоминанием ssh_host.""" + r = client.post("/api/projects", json={ + "id": "ops_err_msg", + "name": "Check Error Message", + "path": "", + "project_type": "operations", + }) + assert r.status_code == 422 + body = r.json() + # Pydantic возвращает detail со списком ошибок + detail_str = str(body) + assert "ssh_host" in detail_str def test_create_research_project_type_accepted(client):