diff --git a/core/models.py b/core/models.py index b21b1af..fe85d36 100644 --- a/core/models.py +++ b/core/models.py @@ -11,10 +11,10 @@ from datetime import datetime from typing import Any -VALID_TASK_STATUSES = [ +VALID_TASK_STATUSES = frozenset({ "pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled", "revising", -] +}) VALID_COMPLETION_MODES = {"auto_complete", "review"} @@ -274,19 +274,29 @@ def get_children(conn: sqlite3.Connection, task_id: str) -> list[dict]: return _rows_to_list(rows) -def has_open_children(conn: sqlite3.Connection, task_id: str) -> bool: +def has_open_children(conn: sqlite3.Connection, task_id: str, visited: set[str] | None = None) -> bool: """Recursively check if task has any open (not done/cancelled) descendants.""" + if visited is None: + visited = set() + if task_id in visited: + return False + visited = visited | {task_id} children = get_children(conn, task_id) for child in children: if child["status"] not in ("done", "cancelled"): return True - if has_open_children(conn, child["id"]): + if has_open_children(conn, child["id"], visited): return True return False -def _check_parent_completion(conn: sqlite3.Connection, task_id: str) -> None: +def _check_parent_completion(conn: sqlite3.Connection, task_id: str, visited: set[str] | None = None) -> None: """Cascade-check upward: if parent is 'revising' and all children closed → promote to 'done'.""" + if visited is None: + visited = set() + if task_id in visited: + return + visited = visited | {task_id} task = get_task(conn, task_id) if not task: return @@ -305,7 +315,7 @@ def _check_parent_completion(conn: sqlite3.Connection, task_id: str) -> None: (now, now, parent_id), ) conn.commit() - _check_parent_completion(conn, parent_id) + _check_parent_completion(conn, parent_id, visited) VALID_TASK_SORT_FIELDS = frozenset({ diff --git a/tests/test_models.py b/tests/test_models.py index 2da8ea2..3c7ede5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -932,3 +932,80 @@ def test_get_pipeline_logs_ordered_asc(pipeline_conn): logs = models.get_pipeline_logs(db, pid) ids = [log["id"] for log in logs] assert ids == sorted(ids) + + +# --------------------------------------------------------------------------- +# KIN-UI-018: Защита от circular references в has_open_children / +# _check_parent_completion (decision #816, #817) +# --------------------------------------------------------------------------- + +def test_circular_reference_protection_has_open_children_returns_false(conn): + """KIN-UI-018 (decision #816): has_open_children возвращает False при циклической ссылке A→B→A. + + Задачи A и B создаются напрямую в БД с взаимными parent_task_id. + Ожидаемый результат: False (не True, не RecursionError). + """ + models.create_project(conn, "p1", "P1", "/p1") + # Создаём задачи без parent сначала + models.create_task(conn, "P1-CYC-A", "p1", "Task A") + models.create_task(conn, "P1-CYC-B", "p1", "Task B") + # Устанавливаем цикл напрямую в БД, минуя валидацию API + conn.execute("UPDATE tasks SET parent_task_id = 'P1-CYC-B' WHERE id = 'P1-CYC-A'") + conn.execute("UPDATE tasks SET parent_task_id = 'P1-CYC-A' WHERE id = 'P1-CYC-B'") + conn.commit() + + result_a = models.has_open_children(conn, "P1-CYC-A") + result_b = models.has_open_children(conn, "P1-CYC-B") + + assert result_a is False + assert result_b is False + + +def test_circular_reference_protection_check_parent_completion_returns_without_error(conn): + """KIN-UI-018 (decision #817): _check_parent_completion не падает и не зависает при цикле A→B→A. + + Задачи A и B в статусе 'revising' с взаимными parent_task_id. + Ожидаемый результат: возврат без RecursionError, статус задач не изменился. + """ + models.create_project(conn, "p1", "P1", "/p1") + models.create_task(conn, "P1-CPC-A", "p1", "Task A", status="revising") + models.create_task(conn, "P1-CPC-B", "p1", "Task B", status="revising") + # Устанавливаем цикл напрямую в БД + conn.execute("UPDATE tasks SET parent_task_id = 'P1-CPC-B' WHERE id = 'P1-CPC-A'") + conn.execute("UPDATE tasks SET parent_task_id = 'P1-CPC-A' WHERE id = 'P1-CPC-B'") + conn.commit() + + # Не должно бросить RecursionError или зависнуть + models._check_parent_completion(conn, "P1-CPC-A") + models._check_parent_completion(conn, "P1-CPC-B") + + # Статусы не изменились — цикл обнаружен и прерван + task_a = models.get_task(conn, "P1-CPC-A") + task_b = models.get_task(conn, "P1-CPC-B") + assert task_a["status"] == "revising" + assert task_b["status"] == "revising" + + +# --------------------------------------------------------------------------- +# KIN-UI-020: VALID_TASK_STATUSES — frozenset membership checks +# --------------------------------------------------------------------------- + +def test_valid_task_statuses_is_frozenset(): + """KIN-UI-020: VALID_TASK_STATUSES должен быть frozenset, не list.""" + assert isinstance(models.VALID_TASK_STATUSES, frozenset) + + +@pytest.mark.parametrize("status", [ + "pending", "in_progress", "review", "done", + "blocked", "decomposed", "cancelled", "revising", +]) +def test_valid_task_statuses_membership(status): + """KIN-UI-020: каждый валидный статус присутствует в VALID_TASK_STATUSES (membership check).""" + assert status in models.VALID_TASK_STATUSES + + +def test_invalid_status_not_in_valid_task_statuses(): + """KIN-UI-020: невалидный статус отсутствует в VALID_TASK_STATUSES.""" + assert "invalid_status" not in models.VALID_TASK_STATUSES + assert "" not in models.VALID_TASK_STATUSES + assert "active" not in models.VALID_TASK_STATUSES diff --git a/tests/test_revising_status.py b/tests/test_revising_status.py index bffa5e7..0fd7fff 100644 --- a/tests/test_revising_status.py +++ b/tests/test_revising_status.py @@ -252,21 +252,14 @@ def test_list_tasks_parent_none_filter(proj): # 8. API tests # =========================================================================== -def test_patch_task_revising_status(client, tmp_path): +def test_patch_task_revising_status(client): """PATCH /api/tasks/{id} status=done с открытым child → ответ содержит status='revising'.""" - import web.api as api_module - from core.db import init_db - from core import models as m - client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"}) parent_id = r.json()["id"] - # Seed child directly via models (TaskCreate API has no parent_task_id field) - conn = init_db(api_module.DB_PATH) - m.create_task(conn, parent_id + "a", "p1", "Child Task", - parent_task_id=parent_id, status="pending") - conn.close() + client.post("/api/tasks", json={"project_id": "p1", "title": "Child Task", + "parent_task_id": parent_id}) r = client.patch(f"/api/tasks/{parent_id}", json={"status": "done"}) @@ -276,19 +269,14 @@ def test_patch_task_revising_status(client, tmp_path): def test_get_children_endpoint(client): """GET /api/tasks/{id}/children возвращает список прямых дочерних задач.""" - import web.api as api_module - from core.db import init_db - from core import models as m - client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"}) parent_id = r.json()["id"] - # Seed children via models - conn = init_db(api_module.DB_PATH) - m.create_task(conn, parent_id + "a", "p1", "Child A", parent_task_id=parent_id) - m.create_task(conn, parent_id + "b", "p1", "Child B", parent_task_id=parent_id) - conn.close() + client.post("/api/tasks", json={"project_id": "p1", "title": "Child A", + "parent_task_id": parent_id}) + client.post("/api/tasks", json={"project_id": "p1", "title": "Child B", + "parent_task_id": parent_id}) r = client.get(f"/api/tasks/{parent_id}/children") @@ -307,19 +295,13 @@ def test_get_children_endpoint_not_found(client): def test_list_tasks_parent_filter_api(client): """GET /api/tasks?parent_task_id={id} фильтрует задачи по родителю.""" - import web.api as api_module - from core.db import init_db - from core import models as m - client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"}) parent_id = r.json()["id"] client.post("/api/tasks", json={"project_id": "p1", "title": "Other Root Task"}) - # Seed child via models - conn = init_db(api_module.DB_PATH) - m.create_task(conn, parent_id + "a", "p1", "Child Task", parent_task_id=parent_id) - conn.close() + client.post("/api/tasks", json={"project_id": "p1", "title": "Child Task", + "parent_task_id": parent_id}) r = client.get(f"/api/tasks?parent_task_id={parent_id}") diff --git a/web/api.py b/web/api.py index 6da7021..ff6b510 100644 --- a/web/api.py +++ b/web/api.py @@ -1864,7 +1864,8 @@ def send_chat_message(project_id: str, body: ChatMessageIn): DIST = Path(__file__).parent / "frontend" / "dist" -app.mount("/assets", StaticFiles(directory=str(DIST / "assets")), name="assets") +if (DIST / "assets").exists(): + app.mount("/assets", StaticFiles(directory=str(DIST / "assets")), name="assets") @app.get("/{path:path}")