fix(commands): проверка типов параметров — 422 вместо 500 (#13)

M-3 (hub#12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 12:41:44 +04:00
parent 7a2f2e694b
commit a309c9bfb1
2 changed files with 48 additions and 0 deletions

View File

@ -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")):

View File

@ -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