"""Tests for KIN-127: 'revising' status and recursive child completion blocking. Covers: - VALID_TASK_STATUSES contains 'revising' - update_task → done redirects to 'revising' when open children exist - Cascade: child→done triggers parent revising→done - completed_at semantics for revising vs real done - get_children() and has_open_children() - API: PATCH status=done, GET /children, GET ?parent_task_id """ import pytest from core.db import init_db from core import models from unittest.mock import patch # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def conn(): """Fresh in-memory DB for each test.""" c = init_db(db_path=":memory:") yield c c.close() @pytest.fixture def proj(conn): """DB connection with a seeded project.""" models.create_project(conn, "p1", "P1", "/p1") return conn # --------------------------------------------------------------------------- # API client fixture (mirrors test_api.py pattern) # --------------------------------------------------------------------------- @pytest.fixture def client(tmp_path): import web.api as api_module api_module.DB_PATH = tmp_path / "test.db" from web.api import app from fastapi.testclient import TestClient return TestClient(app) # =========================================================================== # 1. VALID_TASK_STATUSES # =========================================================================== def test_revising_in_valid_statuses(): """'revising' должен присутствовать в VALID_TASK_STATUSES.""" assert "revising" in models.VALID_TASK_STATUSES # =========================================================================== # 2. update_task: done → revising when open children exist # =========================================================================== def test_done_with_open_children_becomes_revising(proj): """Попытка parent→done при открытом child возвращает status='revising'.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child", parent_task_id="P1-001", status="pending") result = models.update_task(proj, "P1-001", status="done") assert result["status"] == "revising" def test_done_without_children_stays_done(proj): """Задача без детей переходит в 'done' без изменений.""" models.create_task(proj, "P1-001", "p1", "Task") result = models.update_task(proj, "P1-001", status="done") assert result["status"] == "done" def test_done_with_all_children_done(proj): """Все дети завершены → parent→done проходит как обычно.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child A", parent_task_id="P1-001", status="done") models.create_task(proj, "P1-001b", "p1", "Child B", parent_task_id="P1-001", status="done") result = models.update_task(proj, "P1-001", status="done") assert result["status"] == "done" def test_done_with_cancelled_children(proj): """Дети со статусом 'cancelled' считаются закрытыми → parent→done разрешён.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child A", parent_task_id="P1-001", status="cancelled") models.create_task(proj, "P1-001b", "p1", "Child B", parent_task_id="P1-001", status="done") result = models.update_task(proj, "P1-001", status="done") assert result["status"] == "done" # =========================================================================== # 3. Cascade: child→done → parent revising→done # =========================================================================== def test_cascade_parent_completion(proj): """child→done автоматически переводит parent из 'revising' в 'done'.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child", parent_task_id="P1-001", status="pending") # parent→done с открытым ребёнком → revising models.update_task(proj, "P1-001", status="done") assert models.get_task(proj, "P1-001")["status"] == "revising" # child→done → каскад поднимает parent в done models.update_task(proj, "P1-001a", status="done") assert models.get_task(proj, "P1-001")["status"] == "done" def test_deep_cascade(proj): """3 уровня: grandchild→done → child (revising)→done → parent (revising)→done.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child", parent_task_id="P1-001", status="pending") models.create_task(proj, "P1-001a1", "p1", "Grandchild", parent_task_id="P1-001a", status="pending") # parent→done → revising (есть открытый child, у child открытый grandchild) models.update_task(proj, "P1-001", status="done") assert models.get_task(proj, "P1-001")["status"] == "revising" # child→done → revising (есть открытый grandchild) models.update_task(proj, "P1-001a", status="done") assert models.get_task(proj, "P1-001a")["status"] == "revising" # parent остаётся revising assert models.get_task(proj, "P1-001")["status"] == "revising" # grandchild→done → cascades: child→done, parent→done models.update_task(proj, "P1-001a1", status="done") assert models.get_task(proj, "P1-001a1")["status"] == "done" assert models.get_task(proj, "P1-001a")["status"] == "done" assert models.get_task(proj, "P1-001")["status"] == "done" # =========================================================================== # 4. completed_at semantics # =========================================================================== def test_completed_at_not_set_on_revising(proj): """При переходе в 'revising' completed_at должен оставаться NULL.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child", parent_task_id="P1-001", status="pending") result = models.update_task(proj, "P1-001", status="done") assert result["status"] == "revising" assert result["completed_at"] is None def test_completed_at_set_on_real_done(proj): """При каскадном переходе parent из revising → done ставится completed_at.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child", parent_task_id="P1-001", status="pending") models.update_task(proj, "P1-001", status="done") # → revising models.update_task(proj, "P1-001a", status="done") # cascade → parent done parent = models.get_task(proj, "P1-001") assert parent["status"] == "done" assert parent["completed_at"] is not None # =========================================================================== # 5. get_children() # =========================================================================== def test_get_children_returns_direct_children(proj): """get_children возвращает только прямых потомков.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child A", parent_task_id="P1-001") models.create_task(proj, "P1-001b", "p1", "Child B", parent_task_id="P1-001") models.create_task(proj, "P1-001a1", "p1", "Grandchild", parent_task_id="P1-001a") children = models.get_children(proj, "P1-001") assert len(children) == 2 ids = {c["id"] for c in children} assert ids == {"P1-001a", "P1-001b"} def test_get_children_empty_for_leaf_task(proj): """get_children возвращает [] для задачи без детей.""" models.create_task(proj, "P1-001", "p1", "Task") assert models.get_children(proj, "P1-001") == [] # =========================================================================== # 6. has_open_children() — recursive # =========================================================================== def test_has_open_children_recursive(proj): """has_open_children рекурсивно проверяет через 3 уровня вложенности.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child", parent_task_id="P1-001", status="done") models.create_task(proj, "P1-001a1", "p1", "Grandchild", parent_task_id="P1-001a", status="pending") # Child сам done, но у него открытый grandchild — должен вернуть True assert models.has_open_children(proj, "P1-001") is True def test_has_open_children_false_when_all_closed(proj): """has_open_children возвращает False когда все потомки closed.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child", parent_task_id="P1-001", status="done") models.create_task(proj, "P1-001a1", "p1", "Grandchild", parent_task_id="P1-001a", status="cancelled") assert models.has_open_children(proj, "P1-001") is False # =========================================================================== # 7. list_tasks — parent_task_id filter # =========================================================================== def test_list_tasks_parent_filter(proj): """list_tasks(parent_task_id=...) возвращает только дочерние задачи.""" models.create_task(proj, "P1-001", "p1", "Parent") models.create_task(proj, "P1-001a", "p1", "Child A", parent_task_id="P1-001") models.create_task(proj, "P1-001b", "p1", "Child B", parent_task_id="P1-001") models.create_task(proj, "P1-002", "p1", "Other root task") result = models.list_tasks(proj, parent_task_id="P1-001") assert len(result) == 2 ids = {t["id"] for t in result} assert ids == {"P1-001a", "P1-001b"} def test_list_tasks_parent_none_filter(proj): """list_tasks(parent_task_id='__none__') возвращает только корневые задачи.""" models.create_task(proj, "P1-001", "p1", "Root Task") models.create_task(proj, "P1-001a", "p1", "Child", parent_task_id="P1-001") result = models.list_tasks(proj, parent_task_id="__none__") ids = {t["id"] for t in result} assert "P1-001" in ids assert "P1-001a" not in ids # =========================================================================== # 8. API tests # =========================================================================== def test_patch_task_revising_status(client): """PATCH /api/tasks/{id} status=done с открытым child → ответ содержит status='revising'.""" 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": "Child Task", "parent_task_id": parent_id}) r = client.patch(f"/api/tasks/{parent_id}", json={"status": "done"}) assert r.status_code == 200 assert r.json()["status"] == "revising" def test_get_children_endpoint(client): """GET /api/tasks/{id}/children возвращает список прямых дочерних задач.""" 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": "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") assert r.status_code == 200 children = r.json() assert len(children) == 2 titles = {c["title"] for c in children} assert titles == {"Child A", "Child B"} def test_get_children_endpoint_not_found(client): """GET /api/tasks/{id}/children для несуществующей задачи → 404.""" r = client.get("/api/tasks/NONEXISTENT/children") assert r.status_code == 404 def test_list_tasks_parent_filter_api(client): """GET /api/tasks?parent_task_id={id} фильтрует задачи по родителю.""" 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"}) 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}") assert r.status_code == 200 tasks = r.json() assert len(tasks) == 1 assert tasks[0]["title"] == "Child Task"