kin/tests/test_revising_status.py
2026-03-18 21:46:06 +02:00

311 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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