diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index f44a249..a1752fc 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -22,6 +22,8 @@ SPEC = { "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]}, } WRITE_TYPES = [t for t in SPEC if t != "refresh"] +LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"} +BOOL_PARAMS = {"replace"} class CommandError(ValueError): @@ -49,6 +51,23 @@ def temp_keys_used(cmd) -> list[str]: return [k for k in referenced_keys(cmd) if is_temp(k) and k != own] +def _type_error(ctype, name, value) -> str | None: + """Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500. + null допустим везде — обязательность проверяется отдельно.""" + if value is None: + return None + if name in LIST_PARAMS: + ok, want = isinstance(value, list) and all(isinstance(v, str) for v in value), "список строк" + elif name in BOOL_PARAMS: + ok, want = isinstance(value, bool), "true или false" + elif ctype == "ensure_status" and name == "target": + ok = isinstance(value, str) or (isinstance(value, list) and all(isinstance(v, str) for v in value)) + want = "строка или список строк" + else: + ok, want = isinstance(value, str), "строка" + return None if ok else f"{name} — {want}" + + def validate(commands) -> list[dict]: if not isinstance(commands, list) or not commands: raise CommandError("пакет без команд") @@ -68,6 +87,9 @@ def validate(commands) -> list[dict]: unknown = set(params) - set(spec["required"]) - set(spec["optional"]) if unknown: raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}") + bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e] + if bad: + raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad)) if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]): raise CommandError(f"команда {i}: ensure_fields без полей") if ctype == "refresh" and not (params.get("keys") or params.get("jql")): diff --git a/tests/test_commands.py b/tests/test_commands.py index 9e42b2f..daae8eb 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -123,3 +123,29 @@ def test_validate_rejects_duplicate_issue_summary(): 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