sdlc-hub/tests/test_commands.py
Andrey Epifancev a88bb1e6e8 refactor(sdlc-rename): пакет sdlc_hub, CLI sdlc-hub, каталог данных sdlc-hub
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 11:27:17 +04:00

269 lines
17 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.

import pytest
from sdlc_hub.modules.jira import commands, mirror
from sdlc_hub.modules.jira.commands import CommandError
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"
def test_validate_ok_and_tail_keys():
cmds = commands.validate([
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Т"}},
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
{"type": "refresh", "params": {"keys": ["A-1", "new:60"]}},
])
assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_link", "refresh"]
assert commands.referenced_keys(cmds[1]) == ["new:60", "A-1"]
assert commands.temp_keys_used(cmds[1]) == ["new:60"]
assert commands.temp_keys_used(cmds[0]) == []
assert commands.temp_keys_used(cmds[2]) == ["new:60"]
@pytest.mark.parametrize("bad, msg", [
([], "без команд"),
([{"type": "ensure_all"}], "неизвестный тип"),
([{"type": "ensure_fields", "params": {"key": "A-1"}}], "без полей"),
([{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "foo": 1}}], "лишние параметры"),
([{"type": "ensure_status", "params": {"key": "A-1"}}], "нет target"),
([{"type": "refresh", "params": {}}], "без keys и jql"),
([{"type": "ensure_link", "params": {"source": "new:3", "target": "A-1", "link_type": "связанные"}}], "не объявлен"),
([{"type": "ensure_issue", "params": {"temp_key": "A-1", "issue_type": "task", "summary": "x"}}], "new:<n>"),
([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "story", "summary": "x"}}],
"task, epic или bug"),
([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "x"}},
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "y"}}], "уже объявлен"),
])
def test_validate_errors(bad, msg):
with pytest.raises(CommandError, match=msg):
commands.validate(bad)
def test_missing_param_error_names_expected():
"""Клиент со старым описанием исправляет пакет с одной попытки (#44): ensure_status {key, status}."""
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "status": "Готово"}}])
assert str(e.value) == "команда 1 (ensure_status): нет target; ожидаются key, target"
def test_unknown_param_error_names_expected():
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2", "board": "1"}}])
assert str(e.value) == "команда 1 (ensure_sprint): лишние параметры board; ожидаются key, sprint"
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные",
"force": True}}])
assert str(e.value) == ("команда 1 (ensure_link): лишние параметры force; "
"ожидаются source, target, link_type; необязательные replace")
def test_validate_accepts_bug_issue_type():
"""bug-and-attachments: тип «Ошибка» — issue_type = bug; наличие настройки проверяет постановка (queue)."""
cmds = commands.validate([{"type": "ensure_issue", "params": {
"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}])
assert cmds[0]["params"]["issue_type"] == "bug"
assert commands.SPEC["ensure_issue"]["hints"]["issue_type"] == "task|epic|bug"
ABC = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" # sha256("abc")
def test_ensure_attachment_contract():
ok = commands.validate([
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "Ошибка"}},
{"type": "ensure_attachment", "params": {"key": "new:1", "file": ABC, "filename": "ОСВ 60.xlsx"}}])
assert commands.referenced_keys(ok[1]) == ["new:1"] and commands.temp_keys_used(ok[1]) == ["new:1"]
assert "ensure_attachment" in commands.WRITE_TYPES
assert ("- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), "
"filename (имя вложения)") in commands.describe().splitlines()
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC}}])
assert str(e.value) == "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename"
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC, "filename": "a", "size": 3}}])
assert str(e.value) == "команда 1 (ensure_attachment): лишние параметры size; ожидаются key, file, filename"
with pytest.raises(CommandError, match="file — строка"):
commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": 5, "filename": "a"}}])
def test_spec_hints_name_known_params():
for ctype, spec in commands.SPEC.items():
assert set(spec.get("hints", {})) <= set(spec["required"]) | set(spec["optional"]), ctype
def test_substitute_and_estimate():
p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"})
assert p["source"] == "A-9"
p = commands.substitute("refresh", {"keys": ["new:60", "A-1"]}, {"new:60": "A-9"})
assert p["keys"] == ["A-9", "A-1"]
p = commands.substitute("ensure_issue", {"temp_key": "new:60", "issue_type": "task", "summary": "x", "epic_key": "new:2"},
{"new:2": "A-7", "new:60": "A-9"})
assert p["temp_key"] == "new:60" and p["epic_key"] == "A-7"
@pytest.fixture
def conn(open_db):
"""Зеркало в памяти: A-1 (Готово, Спринт 2, метки AWPCA и kind-dev) и A-2, связь A-1 → A-2; закрывается после теста."""
c = open_db()
mirror.apply_state(c, [
{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий",
"duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2",
"created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"},
{"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"},
], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
return c
LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"}
@pytest.mark.parametrize("cmd, expected", [
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "priority": "Высокий"}}, True),
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}, False),
({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["kind-dev"], "labels_remove": ["fact-done"]}}, True),
({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["kind-dev"]}}, False),
({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}}, True),
({"type": "ensure_status", "params": {"key": "A-1", "target": "Разработка"}}, False),
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, True),
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "блокирует начало"}}, False),
({"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, False),
({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}, True),
({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "backlog"}}, False),
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": "b"}}, False),
({"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": "a.xlsx"}}, False),
({"type": "refresh", "params": {"keys": ["A-1"]}}, False),
({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False),
])
def test_check_confirmed(conn, cmd, expected):
ok, _ = commands.check_confirmed(conn, cmd, LT)
assert ok is expected
def test_ensure_link_reverse_direction_with_both_keys_in_mirror(conn):
# A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT)
assert ok is False
def test_ensure_link_conflict_other_type_blocks_confirmation(conn):
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False
def test_ensure_link_conflict_reverse_blocks_confirmation(conn):
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False
def _ensure_issue(summary):
return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}
def test_ensure_issue_never_confirmed_by_state(conn):
"""bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом."""
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None)
def test_validate_rejects_duplicate_issue_summary():
cmds = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Отчёт"}},
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": " Отчёт "}},
{"type": "ensure_issue", "params": {"temp_key": "new:3", "issue_type": "task", "summary": "отчёт"}}]
with pytest.raises(commands.CommandError) as e:
commands.validate(cmds)
assert "повторяется тема ensure_issue: «Отчёт»" in str(e.value)
commands.validate([cmds[0], cmds[2]]) # регистр различается — пакет допустим
@pytest.mark.parametrize("cmd, name", [
({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": 42}}, "summary"),
({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": "AWPCA"}}, "labels_add"),
({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["x", 1]}}, "labels_remove"),
({"type": "ensure_fields", "params": {"key": ["A-1"], "duedate": "2026-10-23"}}, "key"),
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": {"t": 1}}}, "body"),
({"type": "ensure_status", "params": {"key": "A-1", "target": 5}}, "target"),
({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", 2]}}, "target"),
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": "да"}}, "replace"),
({"type": "refresh", "params": {"keys": "A-1"}}, "keys"),
])
def test_validate_rejects_wrong_types(cmd, name):
with pytest.raises(commands.CommandError) as e:
commands.validate([cmd])
assert name in str(e.value)
def test_validate_accepts_null_for_clearable_and_path_target():
out = commands.validate([
{"type": "ensure_fields", "params": {"key": "A-1", "assignee": None, "epic_key": None, "duedate": None}},
{"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}},
{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}},
])
assert len(out) == 3
@pytest.mark.parametrize("mirror_value, param, expected", [
("a\r\nb", "a\nb", True), ("a\nb", "a\r\nb", True), ("a\r\nb\r\n", "a\nb\n", True),
("a\r\nb", "a b", False), (None, "", True), ("", None, True), ("a\rb", "a\nb", False),
])
def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expected):
"""O-9 #16, решение 11: CRLF и LF не различаются, null и пустая строка равны; одиночный CR — другой текст.
summary с CRLF в зеркале и LF в команде — в каждом случае: сравнение одно для всех строковых полей."""
conn.execute("UPDATE issue SET description = ?, summary = ? WHERE key = 'A-1'", (mirror_value, "x\r\ny"))
ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields",
"params": {"key": "A-1", "description": param, "summary": "x\ny"}}, LT)
assert ok is expected
def test_ensure_fields_summary_difference_is_detected(conn):
"""T4.1 #32: CRLF в summary игнорируется, но реальное расхождение текста — False."""
conn.execute("UPDATE issue SET summary = ? WHERE key = 'A-1'", ("x\r\ny",))
ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields",
"params": {"key": "A-1", "summary": "x\nz"}}, LT)
assert ok is False
@pytest.mark.parametrize("ctype, name, value, msg", [
("ensure_fields", "duedate", True, "duedate — строка"),
("ensure_fields", "labels_add", "AWPCA", "labels_add — список строк"),
("ensure_fields", "labels_add", ["x", 1], "labels_add — список строк"),
("ensure_link", "replace", "да", "replace — true или false"),
("ensure_status", "target", 5, "target — строка или список строк"),
("ensure_status", "target", ["Разработка", "Готово"], None),
("ensure_status", "target", "Готово", None),
("ensure_fields", "labels_add", [], None),
("ensure_fields", "assignee", None, None),
("ensure_link", "replace", False, None),
])
def test_type_error_messages(ctype, name, value, msg):
"""T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один)."""
assert commands._type_error(ctype, name, value) == msg
@pytest.mark.parametrize("cmd, name", [
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": True}}, "duedate"),
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": False}}, "body"),
({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": True}}, "summary"),
])
def test_validate_rejects_bool_in_string_param(cmd, name):
"""T3.2 #16: true/false в строковом параметре — 422 с именем параметра, а не запись «True» в Jira."""
with pytest.raises(CommandError, match=f"{name} — строка"):
commands.validate([cmd])
def test_validate_empty_list_params():
"""T3.2 #16: [] в необязательном списке допустим; [] в обязательном или единственном — как отсутствие."""
out = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": [], "labels_remove": []}}])
assert out[0]["params"] == {"key": "A-1", "labels_add": [], "labels_remove": []}
with pytest.raises(CommandError, match="нет target"):
commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "target": []}}])
with pytest.raises(CommandError, match="refresh без keys и jql"):
commands.validate([{"type": "refresh", "params": {"keys": []}}])
def test_fetch_not_confirmed_by_state(conn):
ok, result = commands.check_confirmed(conn, {"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": "5"}}, LT)
assert (ok, result) == (False, None)
assert "fetch_attachment" not in commands.WRITE_TYPES
assert commands.SPEC["fetch_attachment"]["required"] == ["key", "attachment_id"]