SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл <sha> не загружен: POST /files»), filename без / и \, без кавычек и управляющих символов (R-2); claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
16 KiB
Python
263 lines
16 KiB
Python
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:<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"
|
||
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": "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": []}}])
|