kin: auto-commit after pipeline
This commit is contained in:
parent
992dab962a
commit
38aebb7323
2 changed files with 280 additions and 0 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import re
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from core.db import init_db
|
||||
from core import models
|
||||
from core.models import TASK_CATEGORIES
|
||||
|
|
@ -1011,3 +1012,164 @@ def test_invalid_status_not_in_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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-UI-023: _check_parent_completion — атомарность каскада
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_check_parent_completion_happy_path_full_cascade(conn):
|
||||
"""KIN-UI-023 (happy path): grandchild→child→parent→grandparent — все переходят в done атомарно.
|
||||
|
||||
Иерархия: P1-GRAND (revising) → P1-PAR (revising) → P1-CHILD (revising) → P1-GC (done).
|
||||
После _check_parent_completion(P1-GC) все три предка должны стать done за одну транзакцию.
|
||||
"""
|
||||
models.create_project(conn, "p1", "P1", "/p1")
|
||||
models.create_task(conn, "P1-GRAND", "p1", "Grandparent", status="revising")
|
||||
models.create_task(conn, "P1-PAR", "p1", "Parent", status="revising",
|
||||
parent_task_id="P1-GRAND")
|
||||
models.create_task(conn, "P1-CHILD", "p1", "Child", status="revising",
|
||||
parent_task_id="P1-PAR")
|
||||
models.create_task(conn, "P1-GC", "p1", "Grandchild", status="done",
|
||||
parent_task_id="P1-CHILD")
|
||||
conn.commit()
|
||||
|
||||
models._check_parent_completion(conn, "P1-GC")
|
||||
|
||||
child = models.get_task(conn, "P1-CHILD")
|
||||
parent = models.get_task(conn, "P1-PAR")
|
||||
grandparent = models.get_task(conn, "P1-GRAND")
|
||||
|
||||
assert child["status"] == "done"
|
||||
assert parent["status"] == "done"
|
||||
assert grandparent["status"] == "done"
|
||||
assert child["completed_at"] is not None
|
||||
assert parent["completed_at"] is not None
|
||||
assert grandparent["completed_at"] is not None
|
||||
|
||||
|
||||
def test_check_parent_completion_rollback_on_mid_cascade_exception(conn):
|
||||
"""KIN-UI-023 (atomicity): исключение в середине каскада откатывает все изменения.
|
||||
|
||||
Иерархия: P1-GRAND (revising) → P1-PAR (revising) → P1-CHILD (revising) → P1-GC (done).
|
||||
Инжектируем RuntimeError на 2-м вызове has_open_children (при проверке P1-PAR).
|
||||
К тому моменту P1-CHILD уже получил UPDATE→done в pending-транзакции.
|
||||
После rollback ни один из предков не должен остаться в состоянии done.
|
||||
"""
|
||||
models.create_project(conn, "p1", "P1", "/p1")
|
||||
models.create_task(conn, "P1-GRAND", "p1", "Grandparent", status="revising")
|
||||
models.create_task(conn, "P1-PAR", "p1", "Parent", status="revising",
|
||||
parent_task_id="P1-GRAND")
|
||||
models.create_task(conn, "P1-CHILD", "p1", "Child", status="revising",
|
||||
parent_task_id="P1-PAR")
|
||||
models.create_task(conn, "P1-GC", "p1", "Grandchild", status="done",
|
||||
parent_task_id="P1-CHILD")
|
||||
conn.commit()
|
||||
|
||||
call_count = [0]
|
||||
original_has_open_children = models.has_open_children
|
||||
|
||||
def failing_has_open_children(c, tid, visited=None):
|
||||
call_count[0] += 1
|
||||
if call_count[0] >= 2:
|
||||
raise RuntimeError("Simulated mid-cascade failure")
|
||||
return original_has_open_children(c, tid, visited)
|
||||
|
||||
with patch.object(models, "has_open_children", side_effect=failing_has_open_children):
|
||||
with pytest.raises(RuntimeError, match="Simulated mid-cascade failure"):
|
||||
models._check_parent_completion(conn, "P1-GC")
|
||||
|
||||
# После rollback все предки должны оставаться в revising — БД не в промежуточном состоянии
|
||||
child = models.get_task(conn, "P1-CHILD")
|
||||
parent = models.get_task(conn, "P1-PAR")
|
||||
grandparent = models.get_task(conn, "P1-GRAND")
|
||||
assert child["status"] == "revising", "P1-CHILD должен остаться revising после rollback"
|
||||
assert parent["status"] == "revising", "P1-PAR должен остаться revising после rollback"
|
||||
assert grandparent["status"] == "revising", "P1-GRAND должен остаться revising после rollback"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Регрессия: get_decisions — edge case с пустым массивом []
|
||||
# Баг: `if types:` и `if tags:` не различают None (нет фильтра) и [] (пустой фильтр).
|
||||
# Пустой список фальшивый в Python → фильтр не применяется → возвращаются ВСЕ записи.
|
||||
# Ожидаемое поведение: types=[] и tags=[] должны возвращать 0 результатов.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_decisions_empty_types_returns_no_results(conn):
|
||||
"""Регрессия: types=[] должен вернуть 0 решений, а не все.
|
||||
|
||||
Баг: `if types:` фальшивый для [] → фильтр не применяется → возвращаются все решения.
|
||||
Правильное поведение: пустой список типов означает «ни один тип не подходит» → 0 результатов.
|
||||
"""
|
||||
models.create_project(conn, "p1", "P1", "/p1")
|
||||
models.add_decision(conn, "p1", "decision", "Решение A", "описание")
|
||||
models.add_decision(conn, "p1", "gotcha", "Ловушка B", "описание")
|
||||
|
||||
result = models.get_decisions(conn, "p1", types=[])
|
||||
assert result == [], (
|
||||
f"types=[] должен вернуть [], получено {len(result)} записей — "
|
||||
"регрессия: `if types:` не различает None и []"
|
||||
)
|
||||
|
||||
|
||||
def test_get_decisions_empty_tags_returns_no_results(conn):
|
||||
"""Регрессия: tags=[] должен вернуть 0 решений, а не все.
|
||||
|
||||
Баг: `if tags:` фальшивый для [] → фильтр не применяется → возвращаются все решения.
|
||||
Правильное поведение: пустой список тегов означает «ни один тег не подходит» → 0 результатов.
|
||||
"""
|
||||
models.create_project(conn, "p1", "P1", "/p1")
|
||||
models.add_decision(conn, "p1", "gotcha", "Ловушка 1", "desc", tags=["safari", "css"])
|
||||
models.add_decision(conn, "p1", "gotcha", "Ловушка 2", "desc", tags=["chrome"])
|
||||
|
||||
result = models.get_decisions(conn, "p1", tags=[])
|
||||
assert result == [], (
|
||||
f"tags=[] должен вернуть [], получено {len(result)} записей — "
|
||||
"регрессия: `if tags:` не различает None и []"
|
||||
)
|
||||
|
||||
|
||||
def test_get_decisions_none_types_returns_all(conn):
|
||||
"""types=None не должен фильтровать — возвращает все решения (нет фильтра)."""
|
||||
models.create_project(conn, "p1", "P1", "/p1")
|
||||
models.add_decision(conn, "p1", "decision", "Решение A", "описание")
|
||||
models.add_decision(conn, "p1", "gotcha", "Ловушка B", "описание")
|
||||
|
||||
result = models.get_decisions(conn, "p1", types=None)
|
||||
assert len(result) == 2, "types=None должен вернуть все 2 решения"
|
||||
|
||||
|
||||
def test_get_decisions_none_tags_returns_all(conn):
|
||||
"""tags=None не должен фильтровать — возвращает все решения (нет фильтра)."""
|
||||
models.create_project(conn, "p1", "P1", "/p1")
|
||||
models.add_decision(conn, "p1", "gotcha", "Ловушка 1", "desc", tags=["safari"])
|
||||
models.add_decision(conn, "p1", "gotcha", "Ловушка 2", "desc", tags=["chrome"])
|
||||
|
||||
result = models.get_decisions(conn, "p1", tags=None)
|
||||
assert len(result) == 2, "tags=None должен вернуть все 2 решения"
|
||||
|
||||
|
||||
def test_get_decisions_empty_types_differs_from_none(conn):
|
||||
"""types=[] (пустой фильтр → 0 результатов) семантически отличается от types=None (нет фильтра → все)."""
|
||||
models.create_project(conn, "p1", "P1", "/p1")
|
||||
models.add_decision(conn, "p1", "decision", "Решение A", "описание")
|
||||
models.add_decision(conn, "p1", "gotcha", "Ловушка B", "описание")
|
||||
|
||||
result_empty = models.get_decisions(conn, "p1", types=[])
|
||||
result_none = models.get_decisions(conn, "p1", types=None)
|
||||
|
||||
assert len(result_empty) == 0, "types=[] должен давать 0 результатов"
|
||||
assert len(result_none) == 2, "types=None должен давать все 2 результата"
|
||||
|
||||
|
||||
def test_get_decisions_empty_tags_differs_from_none(conn):
|
||||
"""tags=[] (пустой фильтр → 0 результатов) семантически отличается от tags=None (нет фильтра → все)."""
|
||||
models.create_project(conn, "p1", "P1", "/p1")
|
||||
models.add_decision(conn, "p1", "gotcha", "Ловушка 1", "desc", tags=["safari"])
|
||||
models.add_decision(conn, "p1", "gotcha", "Ловушка 2", "desc", tags=["chrome"])
|
||||
|
||||
result_empty = models.get_decisions(conn, "p1", tags=[])
|
||||
result_none = models.get_decisions(conn, "p1", tags=None)
|
||||
|
||||
assert len(result_empty) == 0, "tags=[] должен давать 0 результатов"
|
||||
assert len(result_none) == 2, "tags=None должен давать все 2 результата"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue