311 lines
13 KiB
Python
311 lines
13 KiB
Python
"""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"
|