sdlc-hub/tests/test_agile.py
Andrey Epifancev 1f78d70a02 GET /agile: доска, спринты и бэклог проекта (#108)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:48:14 +04:00

164 lines
8.4 KiB
Python
Raw 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.

from datetime import datetime, timezone
import pytest
from sdlc_hub.core import settings
from sdlc_hub.core.client import HubError
from sdlc_hub.modules.jira import agile, mirror
COLS = [{"name": "К выполнению", "statuses": ["Backlog", "Ожидает анализа"]}, {"name": "В работе", "statuses": ["В работе"]},
{"name": "Готово", "statuses": ["Готово", "Протестировано", "Снято"]}]
@pytest.fixture
def conn(open_db, add_project):
c = open_db()
settings.ensure_defaults(c)
add_project(c)
return c
def issue(key, status, **over):
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "", "status": status,
"priority": "Средний", "assignee": None, "reporter": "me", "duedate": None, "planned_start": None,
"planned_end": None, "epic_key": None, "labels": ["AWPCA"], "sprint": None,
"created": "2026-10-10T12:00:00.000+0300", "jira_updated": "2026-10-11T12:00:00.000+0300"}
return base | over
def kissue(key, status, **over):
return issue(key, status, labels=["K"], **over)
def board(id=4216, type="scrum", sprints=(), **over):
return {"id": id, "type": type, "columns": COLS, "kanban_backlog": None, "sprints": list(sprints), **over}
def sprint(id, state, name=None, **over):
return {"id": id, "name": name or f"S{id}", "state": state, "start_date": None, "end_date": None,
"complete_date": None, "goal": None, **over}
def load(conn, issues, boards):
mirror.apply_state(conn, issues, [], boards=boards)
def keys(rows):
return [r["key"] for r in rows]
def test_sprint_and_backlog_sums(conn): # сценарий «Спринт и бэклог»
load(conn, [issue("A", "Готово", story_points=3, sprint_ids=[102], rank="0|a"), issue("B", "В работе", sprint_ids=[102], rank="0|b"),
issue("C", "Backlog", sprint_ids=[]), issue("D", "Готово", sprint_ids=[])],
[board(sprints=[sprint(102, "active")])])
v = agile.view(conn, "AWPCA")
s = v["sprints"][0]
assert (s["count"], s["count_done"], s["points"], s["points_done"], s["unestimated"]) == (2, 1, 3, 3, 1)
assert keys(s["issues"]) == ["A", "B"] and keys(v["backlog"]) == ["C"] and v["board_issues"] is None
def test_done_is_last_column(conn): # сценарий «Колонки доски»
load(conn, [issue("P", "Протестировано", sprint_ids=[102]), issue("Q", "Backlog", sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
assert {i["key"]: i["done"] for i in agile.view(conn, "AWPCA")["sprints"][0]["issues"]} == {"P": True, "Q": False}
def test_project_without_board(conn, add_project): # сценарий «Проект без доски»
add_project(conn, "STOCKS_DRRK", board_id=None, board_type=None)
assert agile.view(conn, "STOCKS_DRRK") == {"board": None, "sprints": [], "backlog": None, "board_issues": None}
def test_board_without_agent_data(conn): # design #23, состояние 1
load(conn, [issue("A", "Готово")], [])
v = agile.view(conn, "AWPCA")
assert v["board"] == {"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "fetched_at": None}
assert [i["done"] for i in v["backlog"]] == [False] # без колонок выполненных нет
def test_sprint_order_mixed_offsets(conn): # порядок спринтов; Review Focus 1
load(conn, [], [board(sprints=[
sprint(3, "active", start_date="2026-10-14T09:00:00.000+0300"), sprint(5, "active", start_date="2026-10-01T09:00:00.000+03:00"),
sprint(9, "future"), sprint(7, "future"),
sprint(1, "closed", complete_date="2026-10-01T02:00:00.000+0300"), sprint(2, "closed", complete_date="2026-09-30T23:30:00.000Z")])])
assert [s["id"] for s in agile.view(conn, "AWPCA")["sprints"]] == [5, 3, 7, 9, 2, 1] # 2 закрыт позже 1 по UTC
def test_rank_order_nulls_last_then_key(conn):
load(conn, [issue("Z-1", "Backlog", rank="0|i0002:"), issue("Z-2", "Backlog", rank="0|i0001:"),
issue("B-1", "Backlog", rank=None), issue("A-1", "Backlog")], [board()])
assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["Z-2", "Z-1", "A-1", "B-1"]
def test_only_own_issues_and_empty_sprint(conn): # design #8
load(conn, [issue("N", "Backlog", labels=["OTHER"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
s = agile.view(conn, "AWPCA")["sprints"][0]
assert s["issues"] == [] and s["count"] == 0
def test_shared_board_own_issues_only(conn, add_project): # Review Focus 3
add_project(conn, "C", board_id=4216)
load(conn, [issue("A", "Backlog", sprint_ids=[102]), issue("K", "Backlog", labels=["C"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
assert keys(agile.view(conn, "C")["sprints"][0]["issues"]) == ["K"] and keys(agile.view(conn, "AWPCA")["sprints"][0]["issues"]) == ["A"]
def test_closed_sprint_done_by_current_status(conn): # design #7
load(conn, [issue("X", "Готово", sprint_ids=[101])], [board(sprints=[sprint(101, "closed")])])
assert agile.view(conn, "AWPCA")["sprints"][0]["count_done"] == 1
def test_backlog_keeps_issue_of_closed_sprint(conn):
load(conn, [issue("X", "Backlog", sprint_ids=[101]), issue("Y", "Backlog", sprint_ids=[103])],
[board(sprints=[sprint(101, "closed"), sprint(103, "future")])])
assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["X"]
KCOLS = [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]},
{"name": "Готово", "statuses": ["Готово"]}]
NOW = datetime(2026, 10, 24, 12, tzinfo=timezone.utc)
def test_kanban_with_backlog(conn, add_project): # design #3, «Контракт данных»
add_project(conn, "K", board_id=5120, board_type="kanban")
now = datetime(2026, 10, 24, 12, tzinfo=timezone.utc)
load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе"), kissue("K3", "Готово", jira_updated="2026-10-21T10:00:00.000+0300"),
kissue("K4", "Готово", jira_updated="2026-10-01T10:00:00.000+0300"), kissue("K5", "Странный"),
kissue("K6", "Готово", jira_updated=None)],
[board(id=5120, type="kanban", kanban_backlog=True, columns=[{"name": "Бэклог", "statuses": ["Backlog"]},
{"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}])])
v = agile.view(conn, "K", now=now)
assert v["sprints"] == [] and keys(v["backlog"]) == ["K1"] and sorted(keys(v["board_issues"])) == ["K2", "K3", "K5"]
assert v["board"]["kanban_backlog"] is True
def test_kanban_without_backlog(conn, add_project): # design #17
add_project(conn, "K", board_id=5120, board_type="kanban")
load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе")],
[board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)])
v = agile.view(conn, "K", now=NOW)
assert v["backlog"] is None and sorted(keys(v["board_issues"])) == ["K1", "K2"]
assert v["board"]["kanban_backlog"] is False
def test_kanban_done_cutoff_with_offsets(conn, add_project): # Review Focus 1: граница 14 дней по UTC
add_project(conn, "K", board_id=5120, board_type="kanban")
load(conn, [kissue("OLD", "Готово", jira_updated="2026-10-10T14:30:00.000+0300"), # 11:30Z — старше 14 дней
kissue("NEW", "Готово", jira_updated="2026-10-10T15:30:00.000+0300")], # 12:30Z — в пределах
[board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)])
assert keys(agile.view(conn, "K", now=NOW)["board_issues"]) == ["NEW"]
def test_agile_route(hub_server, add_project):
h = hub_server["hub"]
c = h.conn()
try:
add_project(c)
finally:
c.close()
client = hub_server["client"]
with pytest.raises(HubError) as e:
client.get("/agile")
assert e.value.status == 422 and e.value.error == "bad_request"
with pytest.raises(HubError) as e:
client.get("/agile", project="NOPE")
assert e.value.status == 404
assert set(client.get("/agile", project="AWPCA")) == {"board", "sprints", "backlog", "board_issues"}