import pytest from jira_hub import commands, mirror from jira_hub.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:"), ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "x"}}], "task или epic"), ([{"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_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" assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152 @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": "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": []}}])