import re import unicodedata 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:"), ([{"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"] META = {"type": "ensure_sprint_meta", "params": {"sprint": "AWPCA_Sprint_4", "start_date": "2026-10-28", "end_date": "2026-11-11", "goal": "свод ВГО"}} def state(**p): return {"type": "ensure_sprint_state", "params": {"sprint": "AWPCA_Sprint_3_Corrections", **p}} def rank(**p): return {"type": "ensure_rank", "params": {"key": "AURORA-1612", **p}} def test_new_types_validate_ok(): out = commands.validate([META, state(state="active"), state(state="closed", move_open_to="backlog"), rank(before="AURORA-1601"), {"type": "ensure_fields", "params": {"key": "A-1", "story_points": 2.5}}, {"type": "ensure_fields", "params": {"key": "A-1", "story_points": None}}]) assert [c["type"] for c in out][:4] == ["ensure_sprint_meta", "ensure_sprint_state", "ensure_sprint_state", "ensure_rank"] @pytest.mark.parametrize("cmd, msg", [ ({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"), ({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"), ({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"), ({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "нет sprint; ожидаются sprint"), ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"), ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"), (state(state="open"), "state — active или closed"), (state(state="closed"), "move_open_to обязателен при state = closed"), (state(state="active", move_open_to="backlog"), "move_open_to — только при state = closed"), (state(state="closed", move_open_to="AWPCA_Sprint_3_Corrections"), "move_open_to — другой спринт"), (rank(), "ровно одно из before, after"), (rank(before="A-2", after="A-3"), "ровно одно из before, after"), (rank(before="AURORA-1612"), "before — другая задача"), (rank(after="AURORA-1612"), "after — другая задача"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": -1}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": "3"}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": True}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5 ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError ({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "нет sprint; ожидаются sprint, state"), (state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"), (state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"), (state(state="active", move_open_to="x"), "move_open_to — только при state = closed"), (rank(before=" ", after=" "), "ровно одно из before, after"), (rank(before=" AURORA-1612 "), "before — другая задача"), ]) def test_new_types_errors(cmd, msg): with pytest.raises(CommandError, match=re.escape(msg)): commands.validate([cmd]) def test_blank_state_move_open_to_is_unset_when_active(): # R-31: пробельная строка — «не задано» assert commands.validate([state(state="active", move_open_to=" ")]) assert commands.validate([rank(before="A-2", after=" ")]) def test_rank_temp_keys(): ok = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, rank(after="new:1")] assert commands.temp_keys_used(commands.validate(ok)[1]) == ["new:1"] with pytest.raises(CommandError, match="временный ключ new:2 не объявлен"): commands.validate([rank(before="new:2")]) def test_new_types_not_confirmed_by_state(conn): # design #18 for cmd in (META, state(state="active"), rank(before="A-2")): assert commands.check_confirmed(conn, cmd, {}) == (False, None) def test_story_points_confirmation(conn): # R-16 mirror.apply_state(conn, [{"key": "A-1", "labels": [], "story_points": 3}, {"key": "A-2", "labels": []}], []) sp = lambda key, v: {"type": "ensure_fields", "params": {"key": key, "story_points": v}} assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True def test_normalize_trims_identifiers_keeps_text(): out = commands.validate([{"type": "ensure_comment", "params": {"key": " A-1", "marker": "Итог ", "body": " отступ\n"}}]) assert out[0]["params"] == {"key": "A-1", "marker": "Итог", "body": " отступ\n"} def test_normalize_rank_before(): out = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": " A-2 "}}]) assert out[0]["params"] == {"key": "A-1", "before": "A-2"} def test_normalize_temp_key_with_spaces(): out = commands.validate([ {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Т"}}, {"type": "ensure_fields", "params": {"key": "A-5", "epic_key": " new:1 "}}, {"type": "refresh", "params": {"keys": [" new:1"]}}]) assert out[1]["params"]["epic_key"] == "new:1" and out[2]["params"]["keys"] == ["new:1"] def test_normalize_blank_required_is_missing(): with pytest.raises(CommandError, match=r"^команда 1 \(ensure_status\): нет key; ожидаются key, target"): commands.validate([{"type": "ensure_status", "params": {"key": " ", "target": "Готово"}}]) def test_normalize_blank_list_item(): with pytest.raises(CommandError, match="labels_add — без пустых элементов"): commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["x", " "]}}]) def test_normalize_filename_nfc(): nfd = unicodedata.normalize("NFD", "отчёт.pdf") assert nfd != unicodedata.normalize("NFC", "отчёт.pdf") out = commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": f" {nfd} "}}]) assert out[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf") def test_normalize_does_not_mutate_input(): params = {"key": " A-1 ", "target": [" В работе ", "Готово "]} out = commands.validate([{"type": "ensure_status", "params": params}]) assert params["key"] == " A-1 " and out[0]["params"]["target"] == ["В работе", "Готово"] def test_normalize_summary_description_goal_untouched(): out = commands.validate([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": " Т ", "description": " d\n"}}]) assert out[0]["params"]["summary"] == " Т " and out[0]["params"]["description"] == " d\n" def test_is_digits_ascii_only(): from sdlc_hub.core.http import is_digits assert is_digits("123") is True for bad in ("", "²", "١٢", "12a", 7, None): assert is_digits(bad) is False @pytest.mark.parametrize("field", ["assignee", "epic_key", "priority", "duedate", "planned_start"]) def test_ensure_fields_whitespace_only_rejected(field): """I-1 (R-8): « » после обрезки стало бы «» («очистить поле») — отвергается.""" with pytest.raises(CommandError) as e: commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", field: " "}}]) assert f'{field} — из одних пробелов; для очистки — "" или null' in str(e.value) assert "команда 1 (ensure_fields)" in str(e.value) @pytest.mark.parametrize("value", ["", None]) def test_ensure_fields_explicit_clear_accepted(value): cmds = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "assignee": value}}]) assert cmds[0]["params"]["assignee"] == value def test_rank_before_after_whitespace_still_means_not_given(): cmds = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": "A-2", "after": " "}}]) assert cmds[0]["params"]["after"] == ""