sdlc-hub/tests/test_api.py
Andrey Epifancev c8af45ced8 Вики: безопасность и чистка (M-4, M-5, M-6, M-8, M-9, M-10, M-14, M-15 хаб)
- root_set кодирует метку; parse_root вынесен в wiki/roots.py; PATCH wiki_root "" -> 422
- суррогаты в пачке страниц заменяются; dir_name экранирует ведущую точку; error у вложений
- beautifulsoup4 и markdownify<2 объявлены; убраны неиспользуемые импорты и дубль _has_table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:01:54 +04:00

1218 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import base64
import hashlib
import json
import os
import stat
import threading
import time
import urllib.error
import urllib.request
from datetime import timedelta
import pytest
from jira_hub.core import api, db, files, queue, release, settings
from jira_hub.core.client import HubClient, HubError
from jira_hub.modules.jira import api as jira_api
from jira_hub.modules.jira.commands import CommandError
@pytest.fixture
def hub(tmp_path):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
h.claim_wait = 0
conn = h.conn()
settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"})
tok = settings.tokens(conn)
conn.close()
srv = api.make_server(h, "127.0.0.1", 0)
th = threading.Thread(target=srv.serve_forever, daemon=True)
th.start()
base = f"http://127.0.0.1:{srv.server_port}"
yield {"hub": h, "agent": HubClient(base, tok["agents"]["jira"]), "client": HubClient(base, tok["client"]), "base": base}
srv.shutdown()
srv.server_close()
th.join(5)
def _add_project(hub, add_project, *args, **kw):
conn = hub["hub"].conn()
try:
return add_project(conn, *args, **kw)
finally:
conn.close()
def test_auth_scopes(hub):
with pytest.raises(HubError) as e:
hub["agent"].get("/issues")
assert e.value.status == 401
with pytest.raises(HubError) as e:
hub["client"].post("/agent/claim", {})
assert e.value.status == 401
with pytest.raises(HubError) as e:
HubClient(hub["base"], "wrong").get("/issues")
assert e.value.status == 401
with pytest.raises(HubError) as e:
hub["client"].get("/nope")
assert e.value.status == 404
def test_agent_flow(hub):
c = hub["client"]
b = c.post("/batches", {"title": "срок", "author": "claude",
"commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
assert b["count"] == 2
a = hub["agent"]
claim = a.post("/agent/claim", {"agent_version": 1, "settings_version": 1})
cmd = claim["command"]
assert cmd["type"] == "ensure_fields" and cmd["pause_seconds"] == 120 and claim["settings_version"] >= 1
assert a.get(f"/agent/commands/{cmd['id']}")["id"] == cmd["id"]
assert a.get("/agent/settings")["link_types"]["связанные"] == "ASUPR_Связанные"
assert a.post("/agent/claim", {})["command"]["id"] == cmd["id"] # ответ потерян — довыдача той же команды
r = a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"changed": ["duedate"]}, "jira_calls": 2})
assert r["status"] == "done"
assert a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "x"})["status"] == "done"
tail = a.post("/agent/claim", {})["command"]
assert tail["type"] == "refresh"
out = a.post("/agent/state", {"command_id": tail["id"], "page": 1, "last_page": True,
"issues": [{"key": "A-1", "summary": "Т", "duedate": "2026-10-23", "labels": ["AWPCA"],
"created": "2026-10-10T10:00:00.000+0300"},
{"key": "B-1", "summary": "Сосед", "labels": []}],
"links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}],
"neighbor_keys": ["B-1"]})
assert out == {"issues": 2, "links": 1}
assert a.get("/agent/known-keys")["keys"] == ["A-1", "B-1"]
a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1})
assert c.get("/batches/%d" % b["id"])["status"] == "done"
issue = c.get("/issues/A-1")
assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные", "summary": "Сосед", "status": None}]
assert c.get("/issues/B-1")["neighbor"] is True
runs = c.get("/sync-runs", limit=5)["runs"]
assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"]
assert c.get("/agent")["online"] is True
def test_cancel_batch_api_reports_still_claimed(hub):
c, a = hub["client"], hub["agent"]
b = c.post("/batches", {"title": "x", "author": "claude", "commands": [
{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}},
{"type": "ensure_fields", "params": {"key": "A-2", "duedate": "2026-10-23"}}]})
cmd = a.post("/agent/claim", {})["command"]
out = c.post("/batches/%d/cancel" % b["id"], {})
assert out["still_claimed"] == [{"id": cmd["id"], "type": "ensure_fields"}]
def test_interface_routes(hub, add_project):
_add_project(hub, add_project)
c = hub["client"]
with pytest.raises(HubError) as e:
c.get("/issues/A-404")
assert e.value.status == 404
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "sprint": "Спринт 2", "labels": []},
{"key": "A-2", "summary": "Д", "sprint": "Спринт 1", "labels": []}], "links": []})
assert [i["key"] for i in c.get("/issues", sprint="Спринт 2")["issues"]] == ["A-1"]
note = c.put("/issues/A-1/note", {"fact_status": "В работе", "updated_by": "owner"})
assert note["fact_status"] == "В работе" and note["updated_by"] == "owner"
with pytest.raises(HubError) as e:
c.put("/issues/A-1/note", {"summary": "x"})
assert e.value.status == 422
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1"}}]})
assert e.value.status == 422 and "без полей" in e.value.detail
b = c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}},
{"type": "ensure_fields", "params": {"key": "A-2", "duedate": "2026-10-23"}}]})
assert c.get("/batches", limit=5)["batches"][0]["id"] == b["id"]
cmd = hub["agent"].post("/agent/claim", {})["command"]
with pytest.raises(HubError) as e:
c.post(f"/commands/{cmd['id']}/cancel", {})
assert e.value.status == 409 and e.value.detail
assert c.post(f"/commands/{cmd['id']}/cancel", {"force": True})["status"] == "cancelled"
assert c.post(f"/batches/{b['id']}/cancel", {})["status"] == "cancelled"
retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"})
assert c.get(f"/batches/{retry['id']}")["title"] == f"Повтор команды {cmd['id']}"
full = c.post("/refresh", {"full": True})
assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == 'project = AURORA AND labels in ("AWPCA")'
keys = c.post("/refresh", {"keys": ["A-1"]})
assert c.get(f"/batches/{keys['id']}")["commands"][0]["params"] == {"keys": ["A-1"]}
def test_settings_put_rejects_protected(hub):
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
for key in ("tokens", "settings_version"):
with pytest.raises(HubError) as e:
c.put("/settings", {key: "x", "pacing": {"refresh": 1}})
assert e.value.status == 422
assert c.get("/settings")["settings_version"] == v0
assert "tokens" not in c.get("/settings")
v1 = c.put("/settings", {"pacing": {"refresh": 1}})["settings_version"]
assert v1 == v0 + 1 and c.get("/settings")["pacing"]["refresh"] == 1
def test_backup_created_on_first_write(hub, tmp_path):
c = hub["client"]
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
hub["agent"].post("/agent/claim", {})
assert len(list((tmp_path / "backups").glob("*-batch-*.sqlite"))) == 1
def test_client_unavailable():
from jira_hub.core.client import HubUnavailable
with pytest.raises(HubUnavailable):
HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues")
def test_no_backup_for_refresh_batch(hub, tmp_path):
hub["client"].post("/refresh", {"keys": ["A-1"]})
assert hub["agent"].post("/agent/claim", {})["command"]["type"] == "refresh"
assert not list((tmp_path / "backups").glob("*.sqlite"))
def test_bad_requests(hub):
c = hub["client"]
with pytest.raises(HubError) as e:
c.get("/issues", limit="abc")
assert e.value.status == 400
req = urllib.request.Request(hub["base"] + "/batches", data=b"{not json", method="POST",
headers={"Authorization": "Bearer " + c.token})
with pytest.raises(urllib.error.HTTPError) as e2:
urllib.request.urlopen(req)
assert e2.value.code == 400
e2.value.close()
# --- ревью Task 6 --------------------------------------------------------------
def _raw(hub, headers, path="/batches", method="POST"):
import http.client
host, port = hub["base"].removeprefix("http://").split(":")
c = http.client.HTTPConnection(host, int(port), timeout=10)
try:
c.putrequest(method, path)
c.putheader("Authorization", "Bearer " + hub["client"].token)
for k, v in headers.items():
c.putheader(k, v)
c.endheaders()
r = c.getresponse()
return r.status, json.loads(r.read().decode("utf-8"))
finally:
c.close()
@pytest.mark.parametrize("value,status,error", [("-5", 400, "bad_content_length"), ("abc", 400, "bad_content_length"),
(str(api.MAX_BODY + 1), 413, "too_large")])
def test_content_length_checked(hub, value, status, error):
got, payload = _raw(hub, {"Content-Length": value})
assert got == status and payload["error"] == error
def test_numeric_param_400_internal_bug_500(hub, monkeypatch):
with pytest.raises(HubError) as e:
hub["client"].get("/batches", limit="x")
assert e.value.status == 400
def boom(*a):
raise TypeError("внутренний баг")
monkeypatch.setattr(api, "_COMPILED", [("GET", api.re.compile(r"^/issues$"), boom, "client")])
with pytest.raises(HubError) as e:
hub["client"].get("/issues")
assert e.value.status == 500 and e.value.error == "internal"
with pytest.raises(HubError) as e: # сервер жив
hub["client"].get("/issues")
assert e.value.status == 500
def test_tick_survives_connection_failure(hub, monkeypatch, caplog):
def broken():
raise OSError("диск недоступен")
monkeypatch.setattr(hub["hub"], "conn", broken)
hub["hub"].tick() # не бросает
assert "автообновление: сбой тика" in caplog.text
monkeypatch.undo()
hub["hub"].tick()
assert hub["client"].get("/batches")["batches"] == [] # агент не на связи — автообновления нет
def test_claim_long_poll_returns_new_command(hub):
import time
hub["hub"].claim_wait = 4
threading.Timer(0.5, lambda: hub["client"].post("/batches", {"title": "x", "commands": [
{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})).start()
t0 = time.monotonic()
cmd = hub["agent"].post("/agent/claim", {})["command"]
assert cmd and cmd["type"] == "ensure_fields" and time.monotonic() - t0 < 4
def test_claim_long_poll_times_out_with_null(hub):
import time
hub["hub"].claim_wait = 1.5
t0 = time.monotonic()
assert hub["agent"].post("/agent/claim", {})["command"] is None
assert 1.0 <= time.monotonic() - t0 < 4
@pytest.mark.parametrize("path,body", [
("/refresh", {"keys": 5}), ("/refresh", {"keys": "A-1"}), ("/refresh", {"keys": [1]}), ("/refresh", {"jql": 5}),
("/batches", {"title": "x", "commands": 5}), ("/batches", {"title": "x", "commands": "x"}),
("/batches", {"title": ["x"], "commands": []}), ("/batches", {"title": "x", "author": 5, "commands": []}),
])
def test_bad_body_shapes_are_422(hub, path, body):
with pytest.raises(HubError) as e:
hub["client"].post(path, body)
assert e.value.status == 422
def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub):
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
with pytest.raises(HubError) as e:
c.put("/settings", {"pacing": {"refresh": 3}, "tokens": {}})
assert 400 <= e.value.status < 500
assert c.get("/settings")["settings_version"] == v0
@pytest.mark.parametrize("body", [
{"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}},
{"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1},
{"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []},
{"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"},
{"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается
])
def test_settings_put_invalid_value_is_422(hub, body):
c = hub["client"]
before = c.get("/settings")
with pytest.raises(HubError) as e:
c.put("/settings", body)
assert e.value.status == 422
assert c.get("/settings") == before
@pytest.mark.parametrize("body, text", [({"base_jql": "project = AURORA"}, "базовая JQL собирается из проектов"),
({"board_id": 12}, "доска Jira — свойство проекта")])
def test_settings_put_rejects_jql_and_board(hub, body, text):
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
with pytest.raises(HubError) as e:
c.put("/settings", body)
assert e.value.status == 422 and text in str(e.value.detail)
assert c.get("/settings")["settings_version"] == v0
def test_full_refresh_without_projects(hub):
with pytest.raises(HubError) as e:
hub["client"].post("/refresh", {"full": True})
assert e.value.status == 422 and "нет проектов" in str(e.value.detail)
assert hub["client"].get("/batches")["batches"] == []
def test_settings_put_valid_values_pass(hub):
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
out = c.put("/settings", {"pacing": {"refresh": 0, "ensure_fields": 90}, "backup_keep": 0, "port": 8800,
"auto_refresh_minutes": 15, "min_agent_version": 3, "link_types": {"x": "Y"},
"labels": ["AWPCA"]})
assert out["settings_version"] == v0 + 7
got = c.get("/settings")
assert got["pacing"]["ensure_fields"] == 90 and got["port"] == 8800
def _run_refresh(hub, body, issues, neighbor_keys=()):
"""Поставить refresh, взять его агентом, загрузить состояние, отчитаться done."""
hub["client"].post("/refresh", body)
cmd = hub["agent"].post("/agent/claim", {})["command"]
assert cmd["type"] == "refresh"
hub["agent"].post("/agent/state", {"command_id": cmd["id"], "issues": issues, "links": [],
"neighbor_keys": list(neighbor_keys)})
hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done"})
def test_refresh_by_keys_keeps_neighbor_flag(hub, add_project):
"""I-2(b): точечное обновление соседней задачи не опускает флаг."""
_add_project(hub, add_project)
_run_refresh(hub, {"full": True}, [{"key": "A-1", "summary": "т", "labels": []},
{"key": "B-1", "summary": "сосед", "labels": []}], neighbor_keys=["B-1"])
assert hub["client"].get("/issues/B-1")["neighbor"] is True
_run_refresh(hub, {"keys": ["B-1"]}, [{"key": "B-1", "summary": "сосед 2", "labels": []}])
issue = hub["client"].get("/issues/B-1")
assert issue["summary"] == "сосед 2" and issue["neighbor"] is True
def test_full_refresh_marks_unseen_issue_as_neighbor(hub, add_project):
"""I-2(a): полный прогон по базовой JQL: не обновлённая им задача получает neighbor=1."""
_add_project(hub, add_project)
conn = hub["hub"].conn()
conn.execute("INSERT INTO issue(key, summary, labels, fetched_at) VALUES ('X-1', 'вышла из выборки', '[]', '2020-01-01T00:00:00+00:00')")
conn.close()
_run_refresh(hub, {"full": True}, [{"key": "A-1", "summary": "т", "labels": []}])
assert hub["client"].get("/issues/X-1")["neighbor"] is True
assert hub["client"].get("/issues/A-1")["neighbor"] is False
# частичный refresh (по ключам) других задач ничего не помечает
_run_refresh(hub, {"keys": ["A-1"]}, [{"key": "A-1", "summary": "т2", "labels": []}])
assert hub["client"].get("/issues/A-1")["neighbor"] is False
def test_relay_dir_put_validation(hub):
c = hub["client"]
before = c.get("/settings")["settings_version"]
with pytest.raises(HubError) as e:
c.put("/settings", {"relay_dir": "rshb/jira-bridge"})
assert e.value.status == 422
assert c.get("/settings")["settings_version"] == before
assert c.put("/settings", {"relay_dir": "/tmp/jira-bridge"})["settings_version"] == before + 1
assert c.put("/settings", {"relay_dir": None})["settings_version"] == before + 2
assert "relay_dir" not in hub["agent"].get("/agent/settings")
def test_result_for_queued_command_is_409(hub):
b = hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
cid = hub["client"].get(f"/batches/{b['id']}")["commands"][0]["id"]
with pytest.raises(HubError) as e:
hub["agent"].post(f"/agent/commands/{cid}/result", {"status": "done"})
assert e.value.status == 409 and e.value.error == "not_claimed"
def test_duplicate_summary_batch_is_422(hub):
with pytest.raises(HubError) as e:
hub["client"].post("/batches", {"title": "x", "commands": [
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Шахматка: комментарии"}},
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Шахматка: комментарии"}}]})
assert e.value.status == 422 and "Шахматка: комментарии" in e.value.detail
def test_board_route(hub, add_project):
_add_project(hub, add_project)
a, c = hub["agent"], hub["client"]
a.post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "labels": ["kind-dev"]},
{"key": "B-1", "summary": "Сосед", "labels": []}],
"links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}],
"neighbor_keys": ["B-1"]})
snap = c.get("/board")
assert [i["key"] for i in snap["issues"]] == ["A-1", "B-1"]
assert snap["issues"][0]["labels"] == ["kind-dev"] and snap["issues"][1]["neighbor"] is True
assert snap["links"] == [{"source": "A-1", "target": "B-1", "link_type": "ASUPR_Связанные"}]
assert snap["last_sync"] is None
b = c.post("/refresh", {"full": True, "author": "viewer"})
cmd = a.post("/agent/claim", {})["command"]
a.post("/agent/state", {"command_id": cmd["id"], "issues": [{"key": "A-1", "summary": "Т2", "labels": []}], "links": []})
a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
assert c.get(f"/batches/{b['id']}")["author"] == "viewer"
last = c.get("/board")["last_sync"]
assert last["issues_count"] == 1 and last["finished_at"]
for client in (a, HubClient(hub["base"], "")):
with pytest.raises(HubError) as e:
client.get("/board")
assert e.value.status == 401
def _publish(hub, tmp_path, version):
src = tmp_path / f"agent{version}.py"
src.write_text(f"AGENT_VERSION = {version}\nprint('v{version}')\n", encoding="utf-8")
return release.publish(release.module_dir(hub["hub"].release_root, "jira"), src)
def test_claim_release_field(hub, tmp_path):
a = hub["agent"]
assert a.post("/agent/claim", {"agent_version": 5})["agent_release"] is None
m = _publish(hub, tmp_path, 6)
r = a.post("/agent/claim", {"agent_version": 5})
assert r["command"] is None and r["agent_release"] == {"version": 6, "sha256": m["sha256"]}
def test_agent_release_route(hub, tmp_path):
with pytest.raises(HubError) as e:
hub["agent"].get("/agent/release")
assert e.value.status == 404 and e.value.error == "no_release"
m = _publish(hub, tmp_path, 6)
r = hub["agent"].get("/agent/release")
assert r == {"version": 6, "sha256": m["sha256"], "content": "AGENT_VERSION = 6\nprint('v6')\n"}
with pytest.raises(HubError) as e:
hub["client"].get("/agent/release")
assert e.value.status == 401
def test_claim_update_state_shown_in_agent(hub, tmp_path):
_publish(hub, tmp_path, 7)
us = {"state": "rolled_back", "from_version": 6, "to_version": 7, "sha256": "s", "error": "код 1 через 3 с" + "x" * 600,
"at": "2026-10-14T20:00:00+00:00"}
hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": us, "launcher_version": 1})
v = hub["client"].get("/agent")
assert v["release"]["version"] == 7 and v["release"]["published_at"]
assert v["launcher_version"] == 1
assert v["update_state"]["state"] == "rolled_back" and len(v["update_state"]["error"]) == 500
assert v["update_hint"].startswith("откат с v7 на v6: код 1 через 3 с")
def test_claim_without_update_fields(hub, tmp_path):
hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": {"state": "ok", "from_version": 5,
"to_version": 6}, "launcher_version": 1})
hub["agent"].post("/agent/claim", {"agent_version": 4}) # агент v4: полей нет
v = hub["client"].get("/agent")
assert v["launcher_version"] is None and v["update_state"] is None
def test_claim_bad_update_types_ignored(hub):
a = hub["agent"]
a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": 1})
r = a.post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"})
assert "command" in r
a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": True})
v = hub["client"].get("/agent")
assert v["update_state"] == {"state": "ok"} and v["launcher_version"] == 1
def test_hint_without_launcher(hub, tmp_path):
_publish(hub, tmp_path, 6)
hub["agent"].post("/agent/claim", {"agent_version": 5})
v = hub["client"].get("/agent")
assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную"
assert v["agent_reported"] is True
def test_agent_view_without_release(hub):
v = hub["client"].get("/agent")
assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None
assert v["agent_reported"] is False
def test_claim_survives_unreadable_release(hub):
rd = release.module_dir(hub["hub"].release_root, "jira")
(rd / "manifest.json").write_text('{"version": 6, "sha256": "x"}', encoding="utf-8")
os.chmod(rd, 0)
try:
b = hub["client"].post("/batches", {"title": "т", "author": "claude",
"commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
r = hub["agent"].post("/agent/claim", {"agent_version": 5})
assert r["command"] is not None and r["agent_release"] is None # публикация не роняет выдачу команды
assert hub["client"].get("/agent")["release"] is None
with pytest.raises(HubError) as e:
hub["agent"].get("/agent/release")
assert e.value.status == 404
assert b["id"]
finally:
os.chmod(rd, 0o700)
# --- hub-hygiene-d ---------------------------------------------------------------------------------------------
def test_invalid_manifest_one_warning_for_many_claims(hub, monkeypatch, caplog):
"""M-3/M-7 #25, спека «Негодный манифест»: version = 6.9 — публикации нет; 10 claim и GET /agent — одно предупреждение."""
monkeypatch.setattr(release, "_warned", {})
rd = release.module_dir(hub["hub"].release_root, "jira")
(rd / "manifest.json").write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8")
for _ in range(10):
assert hub["agent"].post("/agent/claim", {"agent_version": 5})["agent_release"] is None
assert hub["client"].get("/agent")["release"] is None
with pytest.raises(HubError) as e:
hub["agent"].get("/agent/release")
assert e.value.status == 404 and e.value.error == "no_release"
assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1
def test_agent_release_non_utf8_is_404(hub, tmp_path):
"""M-5 #25: agent.py не в UTF-8 при совпавшем sha256 — 404 no_release, а не 500."""
import hashlib
_publish(hub, tmp_path, 6)
rd = release.module_dir(hub["hub"].release_root, "jira")
data = b"AGENT_VERSION = 6\n\xff\n"
(rd / "agent.py").write_bytes(data)
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
(rd / "manifest.json").write_text(json.dumps({**m, "sha256": hashlib.sha256(data).hexdigest()}), encoding="utf-8")
with pytest.raises(HubError) as e:
hub["agent"].get("/agent/release")
assert e.value.status == 404 and e.value.error == "no_release"
def test_agent_reported_after_hub_restart(hub, tmp_path):
"""M-2 #25, спека «Хаб перезапущен, агент ещё не выходил на связь»: в базе агент онлайн, claim с запуска не было."""
_publish(hub, tmp_path, 10)
conn = hub["hub"].conn()
conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE agent = 'jira'", (db.now(),))
conn.close()
v = hub["client"].get("/agent")
assert v["online"] is True and v["agent_reported"] is False
assert v["launcher_version"] is None and v["update_hint"] is None
hub["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
v = hub["client"].get("/agent")
assert v["agent_reported"] is True and v["launcher_version"] == 2 and v["update_hint"] is None
def test_agent_reported_even_with_bad_update_fields(hub, tmp_path):
"""Review Focus 5: claim с полями неверного типа — claim всё равно был: agent_reported = true."""
_publish(hub, tmp_path, 6)
hub["agent"].post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"})
v = hub["client"].get("/agent")
assert v["agent_reported"] is True and v["launcher_version"] is None
assert v["update_hint"] == "агент запущен без run.py — обновится только вручную"
def test_settings_get_base_jql_from_projects(hub, add_project):
c = hub["client"]
assert c.get("/settings")["base_jql"] is None
_add_project(hub, add_project)
assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA")'
def test_settings_put_returns_all_settings(hub):
"""O-11 #16, решение 9, спека «Ответ на запись настроек»: все настройки после записи, как GET /settings, без токенов."""
c = hub["client"]
v0 = c.get("/settings")["settings_version"]
out = c.put("/settings", {"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"})
assert out["relay_dir"] == "/Volumes/externalSSD/utm/rshb/jira-bridge" and out["settings_version"] == v0 + 1
assert "tokens" not in out and out["base_jql"] is None
assert out == c.get("/settings")
def test_settings_put_empty_body_returns_settings_unchanged(hub):
"""Пустое тело — 200, все настройки, версия не меняется."""
c = hub["client"]
before = c.get("/settings")
assert c.put("/settings", {}) == before
@pytest.mark.parametrize("method", ["DELETE", "OPTIONS"])
def test_other_methods_are_501_not_json(hub, method):
"""M-8 #16, спека «Неподдерживаемый метод»: 501 стандартной страницей http.server, не JSON; база не меняется."""
import http.client
hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields",
"params": {"key": "A-1", "duedate": "2026-10-23"}}]})
host, port = hub["base"].removeprefix("http://").split(":")
conn = http.client.HTTPConnection(host, int(port), timeout=10)
try:
conn.request(method, "/batches/1", headers={"Authorization": "Bearer " + hub["client"].token})
r = conn.getresponse()
r.read()
assert r.status == 501 and not (r.getheader("Content-Type") or "").startswith("application/json")
finally:
conn.close()
assert hub["client"].get("/batches/1")["status"] == "queued"
def test_agent_warning_and_offline(hub):
"""M-10 #4: «обновите агента» при agent_version < min_agent_version; нет контакта дольше порога — офлайн."""
c = hub["client"]
c.put("/settings", {"min_agent_version": 3})
hub["agent"].post("/agent/claim", {"agent_version": 2})
v = c.get("/agent")
assert v["online"] is True and v["warning"] == "обновите агента"
hub["agent"].post("/agent/claim", {"agent_version": 3})
assert c.get("/agent")["warning"] is None
old = (db.utcnow() - timedelta(minutes=queue.ONLINE_MINUTES + 1)).isoformat()
conn = hub["hub"].conn()
conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE agent = 'jira'", (old,))
conn.close()
assert c.get("/agent")["online"] is False
def test_tick_creates_auto_refresh_once(hub, add_project):
"""M-10 #4: тик при агенте на связи и пустой очереди ставит «Автообновление»; второй тик — нет (очередь занята)."""
_add_project(hub, add_project)
hub["agent"].post("/agent/claim", {"agent_version": 9})
hub["hub"].tick()
batches = hub["client"].get("/batches", limit=5)["batches"]
assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub"
hub["hub"].tick()
assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1
def test_agent_state_comments_validated_whole_batch(hub):
a, c = hub["agent"], hub["client"]
a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}], "links": []})
with pytest.raises(HubError) as e:
a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс новый", "labels": []},
{"key": "Y-1", "summary": "Игрек", "labels": [],
"comments": [{"id": "7", "body": 42}]}], "links": []})
assert e.value.status == 422 and e.value.error == "bad_state"
assert "Y-1" in e.value.detail and "body" in e.value.detail
assert c.get("/issues/X-1")["summary"] == "Икс" # пачка не применена целиком
with pytest.raises(HubError) as e:
c.get("/issues/Y-1")
assert e.value.status == 404
def test_agent_state_bad_attachment_422(hub):
a, c = hub["agent"], hub["client"]
with pytest.raises(HubError) as e:
a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []},
{"key": "Y-1", "summary": "Игрек", "labels": [],
"attachments": [{"id": "7", "filename": "a", "size": -1}]}], "links": []})
assert e.value.status == 422 and e.value.error == "bad_state"
assert "Y-1" in e.value.detail and "attachments[0].size" in e.value.detail
with pytest.raises(HubError) as e:
c.get("/issues/X-1")
assert e.value.status == 404 # пачка не применена целиком
def test_issue_detail_with_comments(hub):
hub["agent"].post("/agent/state", {"issues": [
{"key": "A-1", "summary": "Схема", "labels": [], "epic_key": "E-1", "comments": [
{"id": "1", "author": "Петров Пётр", "created": "2026-10-14T12:00:00.000+0300", "updated": None, "body": "старый"},
{"id": "2", "author": None, "created": "2026-10-15T12:00:00.000+0300", "updated": None, "body": "новый"}]},
{"key": "E-1", "summary": "Эпик", "labels": []}], "links": []})
got = hub["client"].get("/issues/A-1")
assert [x["body"] for x in got["comments"]] == ["новый", "старый"]
assert got["epic_summary"] == "Эпик"
def test_bug_without_setting_is_422(hub):
with pytest.raises(HubError) as e:
hub["client"].post("/batches", {"title": "ошибка", "commands": [{"type": "ensure_issue", "params": {
"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]})
assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug"
assert hub["client"].get("/batches")["batches"] == []
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def test_files_upload_and_agent_download(hub, tmp_path):
"""Спека hub-api «Загрузка файла», «Повторная загрузка», «Скачивание файла вложения»."""
c, a = hub["client"], hub["agent"]
sha = hashlib.sha256(b"abc").hexdigest()
out = c.post("/files", {"content": _b64(b"abc")})
assert out == {"file": sha, "size": 3} and sha.startswith("ba7816bf")
assert (tmp_path / "files" / sha).read_bytes() == b"abc"
assert c.post("/files", {"content": _b64(b"abc")}) == out
assert [p.name for p in (tmp_path / "files").iterdir()] == [sha]
got = a.get(f"/agent/files/{sha}")
assert got["file"] == sha and got["size"] == 3
assert hashlib.sha256(base64.b64decode(got["content"])).hexdigest() == sha
with pytest.raises(HubError) as e:
c.get(f"/agent/files/{sha}")
assert e.value.status == 401
with pytest.raises(HubError) as e:
a.post("/files", {"content": _b64(b"abc")})
assert e.value.status == 401
with pytest.raises(HubError) as e:
a.get("/agent/files/" + "0" * 64)
assert (e.value.status, e.value.error) == (404, "no_file")
@pytest.mark.parametrize("body, status, error", [
({}, 400, "bad_request"), ({"content": 5}, 400, "bad_request"), ({"content": None}, 400, "bad_request"),
({"content": "не base64!"}, 400, "bad_request"), ({"content": "YWJj="}, 400, "bad_request"),
({"content": ""}, 422, "empty_file"),
])
def test_files_bad_upload(hub, tmp_path, body, status, error):
with pytest.raises(HubError) as e:
hub["client"].post("/files", body)
assert (e.value.status, e.value.error) == (status, error)
assert list((tmp_path / "files").iterdir()) == []
def test_files_base64_with_line_breaks(hub):
data = bytes(range(256)) * 4
s = _b64(data)
wrapped = "\n".join(s[i:i + 76] for i in range(0, len(s), 76)) + "\n" # как GNU base64
assert hub["client"].post("/files", {"content": wrapped}) == {"file": hashlib.sha256(data).hexdigest(), "size": 1024}
def test_files_too_large_is_413(hub, tmp_path):
"""Спека hub-api «Слишком большой файл»: 11 МБ — 413, тело запроса (≈ 15 МБ base64) до 16 МБ проходит."""
with pytest.raises(HubError) as e:
hub["client"].post("/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))})
assert (e.value.status, e.value.error) == (413, "too_large")
assert list((tmp_path / "files").iterdir()) == []
@pytest.mark.parametrize("bad", ["..", "%2E%2E", hashlib.sha256(b"abc").hexdigest().upper(), "0" * 63, "x" * 64])
def test_agent_file_bad_id_is_no_file(hub, bad):
hub["client"].post("/files", {"content": _b64(b"abc")})
with pytest.raises(HubError) as e:
hub["agent"].get(f"/agent/files/{bad}")
assert (e.value.status, e.value.error) == (404, "no_file")
def test_files_dir_closed_on_every_start(tmp_path):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
d = tmp_path / "files"
assert h.files_dir == d and stat.S_IMODE(d.stat().st_mode) == 0o700
os.chmod(d, 0o755)
api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
assert stat.S_IMODE(d.stat().st_mode) == 0o700
def test_tick_sweeps_files_once_a_day(hub, tmp_path, monkeypatch):
h = hub["hub"]
sha = hub["client"].post("/files", {"content": _b64(b"old")})["file"]
old = time.time() - 31 * 86400
os.utime(tmp_path / "files" / sha, (old, old))
calls = []
real = files.sweep
monkeypatch.setattr(api.files, "sweep", lambda *a, **k: calls.append(1) or real(*a, **k))
h.tick()
assert len(calls) == 1 and not (tmp_path / "files" / sha).exists()
h.tick()
assert len(calls) == 1 # второй тик в те же сутки — без уборки
h.last_sweep -= api.SWEEP_EVERY
h.tick()
assert len(calls) == 2
def test_tick_sweeps_downloads_once_a_day(hub):
h = hub["hub"]
old = time.time() - 31 * 86400
def put_old(name):
d = h.db_path.parent / "downloads" / name
d.mkdir(parents=True)
(d / "a.png").write_bytes(b"x")
os.utime(d / "a.png", (old, old))
os.utime(d, (old, old))
return d
first = put_old("1")
h.tick()
assert not first.exists() and "downloads_at" in h.module_state["jira"]
second = put_old("2")
h.tick()
assert second.exists() # второй тик в те же сутки — без уборки
def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog, add_project):
_add_project(hub, add_project)
def broken(*args, **kwargs):
raise OSError("диск недоступен")
monkeypatch.setattr(api.files, "sweep", broken)
hub["agent"].post("/agent/claim", {"agent_version": 9}) # агент на связи — тик ставит автообновление
hub["hub"].tick() # не бросает
assert "уборка файлов: сбой" in caplog.text
assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"]
def test_attachment_flow_over_api(hub):
"""POST /batches проверяет файл; claim и GET /agent/commands/{id} отдают size; retry — params без size."""
c, a = hub["client"], hub["agent"]
sha = c.post("/files", {"content": _b64(b"abc")})["file"]
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": {
"key": "A-1", "file": "0" * 64, "filename": "a.xlsx"}}]})
assert e.value.status == 422 and e.value.detail == f"команда 1: файл {'0' * 64} не загружен: POST /files"
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": {
"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}}]})
cmd = a.post("/agent/claim", {})["command"]
assert cmd["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx", "size": 3}
assert cmd["pause_seconds"] == 30
assert a.get(f"/agent/commands/{cmd['id']}")["params"]["size"] == 3
a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 500"})
retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"})
assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}
def test_issues_hide_closed(hub):
c = hub["client"]
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []},
{"key": "A-2", "summary": "Т", "status": "Готово", "labels": []},
{"key": "A-3", "summary": "Т", "status": "Снято", "labels": []},
{"key": "A-4", "summary": "Т", "labels": []}], "links": []})
def keys(**q):
return sorted(i["key"] for i in c.get("/issues", **q)["issues"])
assert keys(hide_closed="1") == ["A-1", "A-4"]
assert keys() == ["A-1", "A-2", "A-3", "A-4"]
c.put("/settings", {"closed_statuses": ["Готово"]})
assert keys(hide_closed="1") == ["A-1", "A-3", "A-4"]
def test_create_project(hub): # сценарий «Новый проект»
c = hub["client"]
conn = hub["hub"].conn(); version = settings.get(conn, "settings_version"); conn.close()
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216})
out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": 5120, "author": "viewer"})
assert out["project"]["label"] == "STOCKS_DRRK" and out["project"]["board_type"] is None
got = c.get("/projects")["projects"]
assert [p["label"] for p in got] == ["AWPCA", "STOCKS_DRRK"] and got[1]["issues_count"] == 0
b = c.get(f"/batches/{out['batch_id']}")
assert b["author"] == "viewer" and b["title"] == "Новый проект STOCKS_DRRK" and b["project"] is None
assert b["commands"][0]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}
assert c.get("/settings")["settings_version"] == version # «Создание проекта не трогает версию настроек»
assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'
def test_create_project_without_board(hub):
c = hub["client"]
out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
assert out["project"]["board_id"] is None and out["project"]["board_type"] is None
b = c.get(f"/batches/{out['batch_id']}")
assert b["author"] == "api"
@pytest.mark.parametrize("body, status, field", [
({"label": "AWPCA", "name": "x"}, 409, "label"),
({"label": "awpca", "name": "x"}, 409, "label"),
({"label": "STOCKS DRRK", "name": "x"}, 422, "label"),
({"label": "A\\B", "name": "x"}, 422, "label"),
({"label": "A\nB", "name": "x"}, 422, "label"),
({"label": "A\tB", "name": "x"}, 422, "label"),
({"label": "STOCKS_DRRK", "name": " "}, 422, "name"),
({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"),
({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None),
({"label": "STOCKS_DRRK", "name": "x", "author": 0}, 422, None),
({"label": "STOCKS_DRRK", "name": "x", "author": False}, 422, None),
({"label": "STOCKS_DRRK", "name": "x", "author": []}, 422, None),
({"label": "STOCKS_DRRK", "name": "x", "author": {}}, 422, None),
])
def test_create_project_errors(hub, body, status, field):
c = hub["client"]
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"})
before = len(c.get("/batches")["batches"])
with pytest.raises(HubError) as e:
c.post("/projects", body)
assert e.value.status == status and (field is None or e.value.detail.startswith(field))
assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before
@pytest.mark.parametrize("author", [None, ""])
def test_create_project_empty_author_is_api(hub, author):
c = hub["client"]
out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "x", "author": author})
assert c.get(f"/batches/{out['batch_id']}")["author"] == "api"
def test_create_project_batch_error_rolls_back(hub, monkeypatch): # T3.2
def boom(*a, **k):
raise CommandError("не ставится")
monkeypatch.setattr(queue, "create_batch", boom)
c = hub["client"]
with pytest.raises(HubError) as e:
c.post("/projects", {"label": "STOCKS_DRRK", "name": "x"})
assert e.value.status == 422 and e.value.error == "bad_batch"
assert c.get("/projects")["projects"] == []
def test_projects_need_client_token(hub):
with pytest.raises(HubError) as e:
hub["agent"].get("/projects")
assert e.value.status == 401
with pytest.raises(HubError) as e:
hub["agent"].post("/projects", {"label": "STOCKS_DRRK", "name": "x"})
assert e.value.status == 401
def test_board_and_issues_by_project(hub):
c, a = hub["client"], hub["agent"]
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"})
c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
a.post("/agent/state", {"issues": [
{"key": "A-1", "summary": "Т1", "labels": ["STOCKS_DRRK"]},
{"key": "A-2", "summary": "Т2", "labels": ["AWPCA"]},
{"key": "A-3", "summary": "Т3", "labels": ["AWPCA"]}],
"links": [{"source_key": "A-1", "target_key": "A-2", "link_type": "L"}]})
snap = c.get("/board?project=STOCKS_DRRK")
assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [("A-1", False), ("A-2", True)]
assert [(l["source"], l["target"]) for l in snap["links"]] == [("A-1", "A-2")]
assert snap["last_sync"] == c.get("/board")["last_sync"]
assert len(c.get("/board")["issues"]) == 3
assert [i["key"] for i in c.get("/issues?project=STOCKS_DRRK&neighbor=1")["issues"]] == ["A-2"]
for path in ("/issues?project=NOPE", "/board?project=NOPE"):
with pytest.raises(HubError) as e:
c.get(path)
assert e.value.status == 404 and "project NOPE" in e.value.detail
def test_project_batches_api(hub, add_project): # #61, Task 5
conn = hub["hub"].conn()
try:
add_project(conn)
add_project(conn, "STOCKS_DRRK", board_id=None)
finally:
conn.close()
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "А", "labels": ["AWPCA"]},
{"key": "A-2", "summary": "С", "labels": ["STOCKS_DRRK"]}], "links": []})
c = hub["client"]
cmd = lambda key: [{"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23"}}]
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "x", "project": "STOCKS_DRRK", "commands": cmd("A-1")})
assert e.value.status == 422 and "не в проекте" in e.value.detail
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "x", "project": 5, "commands": cmd("A-2")})
assert e.value.status == 422
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "x", "project": "", "commands": cmd("A-2")})
assert e.value.status == 422 and "project: пустая строка" in e.value.detail
mine = c.post("/batches", {"title": "x", "project": "STOCKS_DRRK", "commands": cmd("A-2")})
c.post("/batches", {"title": "y", "commands": cmd("A-1")})
assert [b["id"] for b in c.get("/batches", project="STOCKS_DRRK")["batches"]] == [mine["id"]]
assert c.get(f"/batches/{mine['id']}")["project"] == "STOCKS_DRRK"
own = c.post("/batches", {"title": "z", "project": "AWPCA", "commands": cmd("A-1")})
cid = c.get(f"/batches/{own['id']}")["commands"][0]["id"]
before = len(c.get("/batches", limit=50)["batches"])
with pytest.raises(HubError) as e:
c.post(f"/commands/{cid}/retry", {"project": "STOCKS_DRRK"})
assert e.value.status == 422 and f"команда #{cid} не из проекта STOCKS_DRRK" in e.value.detail
with pytest.raises(HubError) as e:
c.post(f"/commands/{cid}/retry", {"project": 5})
assert e.value.status == 422
with pytest.raises(HubError) as e:
c.post(f"/commands/{cid}/retry", {"project": ""})
assert e.value.status == 422 and "project: пустая строка" in e.value.detail
assert len(c.get("/batches", limit=50)["batches"]) == before
r = c.post(f"/commands/{cid}/retry", {})
assert c.get(f"/batches/{r['id']}")["project"] == "AWPCA"
def test_agent_command_refresh_has_boards(hub, add_project): # #61, Task 6
_add_project(hub, add_project)
b = hub["client"].post("/batches", {"title": "r", "commands": [{"type": "refresh", "params": {"keys": ["A-1"]}}]})
cid = hub["client"].get(f"/batches/{b['id']}")["commands"][0]["id"]
got = hub["agent"].get(f"/agent/commands/{cid}")
assert got["params"] == {"keys": ["A-1"], "boards": [{"id": 4216, "type": "scrum"}]}
assert "boards" not in hub["client"].get(f"/batches/{b['id']}")["commands"][0]["params"]
def test_board_type_round_trip(hub): # сценарий «Тип доски определён»
c, a = hub["client"], hub["agent"]
c.post("/projects", {"label": "STOCKS_DRRK", "name": "Остатки ДРРК", "board_id": 5120})
cmd = a.post("/agent/claim", {"agent_version": 12, "settings_version": 1})["command"] # refresh от создания проекта
assert cmd["type"] == "refresh" and {"id": 5120, "type": None} in cmd["params"]["boards"]
a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"boards": {"5120": "kanban"}}})
assert [p["board_type"] for p in c.get("/projects")["projects"] if p["label"] == "STOCKS_DRRK"] == ["kanban"]
a.post("/agent/state", {"issues": [{"key": "A-5", "summary": "С", "labels": ["STOCKS_DRRK"]}], "links": []})
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "s", "commands": [{"type": "ensure_sprint", "params": {"key": "A-5", "sprint": "С2"}}]})
assert e.value.status == 422 and "канбан-доска" in e.value.detail
HISTORY_ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров",
"field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001",
"to_string": "Готово"}
def test_agent_history_route(hub):
assert hub["agent"].post("/agent/history", {"command_id": None, "changes": [HISTORY_ITEM]}) == {"accepted": 1, "duplicates": 0}
items = [HISTORY_ITEM, {**HISTORY_ITEM, "seq": 1}, {**HISTORY_ITEM, "seq": 2}]
assert hub["agent"].post("/agent/history", {"changes": items}) == {"accepted": 2, "duplicates": 1}
with pytest.raises(HubError) as e:
hub["client"].post("/agent/history", {"changes": items})
assert e.value.status == 401
with pytest.raises(HubError) as e:
hub["agent"].post("/agent/history", {"changes": [{**HISTORY_ITEM, "seq": 7}, {**HISTORY_ITEM, "seq": "0"}]})
assert e.value.status == 422 and e.value.error == "bad_history" and "changes[1].seq" in e.value.detail
conn = hub["hub"].conn()
try:
assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 3
finally:
conn.close()
def test_sync_runs_history_at(hub):
conn = hub["hub"].conn()
try:
conn.execute("INSERT INTO sync_run(started_at, kind, history_at) VALUES ('2026-10-22T09:00:00+00:00', 'jql', "
"'2026-10-22T09:02:00+00:00')")
finally:
conn.close()
assert hub["client"].get("/sync-runs")["runs"][0]["history_at"] == "2026-10-22T09:02:00+00:00"
def test_changes_route(hub, add_project):
conn = hub["hub"].conn()
try:
add_project(conn)
finally:
conn.close()
hub["agent"].post("/agent/history", {"changes": [{**HISTORY_ITEM, "created": "2026-10-22T11:00:00Z"}]})
got = hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T13:00:00+03:00", kinds="status")
assert got["since"] == "2026-10-22T10:00:00+00:00" and got["until"] == "2026-10-22T10:00:00+00:00"
got = hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T12:00:00Z")
assert [e["key"] for e in got["changes"]] == ["X"] and got["truncated"] is False
assert hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T12:00:00Z",
project="AWPCA")["changes"] == [] # X без метки проекта
default = hub["client"].get("/changes")
assert db.parse(default["until"]) - db.parse(default["since"]) == timedelta(hours=24)
@pytest.mark.parametrize("params, status, name", [
({"since": "вчера"}, 422, "since"), ({"until": "x"}, 422, "until"),
({"since": "2026-10-22T12:00:00Z", "until": "2026-10-22T10:00:00Z"}, 422, "since"),
({"kinds": "foo"}, 422, "kinds"), ({"limit": "0"}, 422, "limit"), ({"limit": "2001"}, 422, "limit"),
({"limit": "x"}, 422, "limit"), ({"project": "NOPE"}, 404, None),
({"since": "2020-01-01T09:00"}, 422, "since"), ({"until": "2026-10-22"}, 422, "until"), # M-2: без смещения
])
def test_changes_route_bad_params(hub, params, status, name):
with pytest.raises(HubError) as e:
hub["client"].get("/changes", **params)
assert e.value.status == status
if name:
assert e.value.error == "bad_request" and name in e.value.detail
def test_issue_has_attachments(hub):
a, c = hub["agent"], hub["client"]
c.post("/batches", {"title": "x", "author": "claude", "commands": [{"type": "refresh", "params": {"keys": ["A-1"]}}]})
cmd = a.post("/agent/claim", {})["command"]
a.post("/agent/state", {"command_id": cmd["id"], "page": 1, "last_page": True, "links": [], "issues": [
{"key": "A-1", "summary": "Т", "labels": [], "description": "!a.png!", "created": "2026-10-10T10:00:00.000+0300",
"attachments": [{"id": "7", "filename": "a.png", "size": 10, "created": "2026-10-10T10:00:00.000+0300"}]}]})
got = c.get("/issues/A-1")
assert got["attachments"][0]["attachment_id"] == "7" and got["attachments"][0]["state"] == "none"
assert got["description_attachment_ids"] == ["7"]
# --- вложения из Jira по запросу (#83) ----------------------------------------------
ATT_NAME = "скрин 1.png"
def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME):
conn = hub["hub"].conn()
try:
from jira_hub.modules.jira import mirror
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
{"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
finally:
conn.close()
def _content(hub, path):
req = urllib.request.Request(hub["base"] + path, headers={"Authorization": "Bearer " + hub["client"].token})
return urllib.request.urlopen(req)
def _fetch_done(hub, data=b"abc"):
"""Запрос скачивания, claim агентом, загрузка файла и отчёт done."""
out = hub["client"].post("/attachments/31337/fetch", {"author": "web"})
cmd = hub["agent"].post("/agent/claim", {})["command"]
up = hub["agent"].post("/agent/files", {"content": _b64(data)})
hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": up})
return out, cmd, up
def test_agent_files_post(hub):
a = hub["agent"]
out = a.post("/agent/files", {"content": _b64(b"abc")})
assert out == {"file": hashlib.sha256(b"abc").hexdigest(), "size": 3}
with pytest.raises(HubError) as e:
hub["client"].post("/agent/files", {"content": _b64(b"abc")})
assert e.value.status == 401
assert a.post("/agent/files", {"content": ""}) == {"file": hashlib.sha256(b"").hexdigest(), "size": 0}
with pytest.raises(HubError) as e:
a.post("/agent/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))})
assert e.value.status == 413 and e.value.error == "too_large"
with pytest.raises(HubError) as e:
a.post("/agent/files", {"content": "***"})
assert e.value.status == 400
def test_attachment_get_404(hub):
for method, path in (("get", "/attachments/1"), ("post", "/attachments/1/fetch"), ("get", "/attachments/1/content")):
with pytest.raises(HubError) as e:
getattr(hub["client"], method)(path)
assert e.value.status == 404 and e.value.error == "no_attachment"
def test_attachment_fetch_flow(hub):
_seed_attachment(hub)
out, cmd, up = _fetch_done(hub)
assert out["state"] == "fetching" and out["command_id"] and out["batch_id"]
assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2
got = hub["client"].get("/attachments/31337")
assert got["state"] == "ready" and got["filename"] == ATT_NAME and got["key"] == "A-1"
resp = _content(hub, "/attachments/31337/content")
assert resp.status == 200 and resp.read() == b"abc"
assert resp.headers["Content-Type"] == "image/png"
assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png"
assert resp.headers["X-Content-Type-Options"] == "nosniff"
assert resp.headers["Content-Length"] == "3"
def test_attachment_content_updates_mtime(hub):
_seed_attachment(hub)
_, _, up = _fetch_done(hub)
p = hub["hub"].files_dir / up["file"]
old = time.time() - 10 * 86400
os.utime(p, (old, old))
_content(hub, "/attachments/31337/content").read()
assert p.stat().st_mtime > time.time() - 60
def test_attachment_content_special_name(hub):
name = "a:b/c d\u00e9.txt"
_seed_attachment(hub, name=name, mime="text/plain")
_fetch_done(hub)
resp = _content(hub, "/attachments/31337/content")
assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''a%3Ab%2Fc%20d%C3%A9.txt"
def test_attachment_content_not_fetched(hub):
_seed_attachment(hub)
with pytest.raises(HubError) as e:
hub["client"].get("/attachments/31337/content")
assert e.value.status == 404 and e.value.error == "not_fetched"
def test_attachment_content_bad_mime(hub):
_seed_attachment(hub, mime="weird")
_fetch_done(hub)
assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream"
def test_attachment_content_non_ascii_mime(hub):
"""Кириллический mime_type не должен ронять send_header (UnicodeEncodeError → 500): \w в MIME_RE — только ASCII."""
_seed_attachment(hub, mime="текст/простой")
_fetch_done(hub)
assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream"
def test_attachment_fetch_twice_same_command(hub):
_seed_attachment(hub)
a = hub["client"].post("/attachments/31337/fetch", {})
b = hub["client"].post("/attachments/31337/fetch", {})
assert a["command_id"] == b["command_id"] and b["state"] == "fetching"
def test_attachment_fetch_too_large_413(hub):
_seed_attachment(hub, size=25_000_000)
with pytest.raises(HubError) as e:
hub["client"].post("/attachments/31337/fetch", {})
assert e.value.status == 413 and e.value.error == "too_large"
assert e.value.detail == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira"
assert hub["client"].get("/batches")["batches"] == []
def test_batches_post_fetch_too_large_413(hub):
"""M-2: POST /batches с fetch_attachment > 10 МБ — 413 too_large, не 422."""
_seed_attachment(hub, size=25_000_000)
with pytest.raises(HubError) as e:
hub["client"].post("/batches", {"title": "x", "commands": [
{"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": "31337"}}]})
assert e.value.status == 413 and e.value.error == "too_large"
assert hub["client"].get("/batches")["batches"] == []
def test_command_retry_fetch_too_large_413(hub):
"""M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large."""
_seed_attachment(hub)
st = hub["client"].post("/attachments/31337/fetch", {})
_seed_attachment(hub, size=25_000_000)
with pytest.raises(HubError) as e:
hub["client"].post(f"/commands/{st['command_id']}/retry", {"author": "claude"})
assert e.value.status == 413 and e.value.error == "too_large"
def test_attachment_fetch_agent_too_old_409(hub):
_seed_attachment(hub)
hub["agent"].post("/agent/claim", {"agent_version": 13})
with pytest.raises(HubError) as e:
hub["client"].post("/attachments/31337/fetch", {})
assert e.value.status == 409 and e.value.error == "agent_too_old"
assert e.value.detail == "агент v13 не умеет скачивать вложения — нужен v14"
def test_sweep_files_downloads(hub):
h = hub["hub"]
d = h.db_path.parent / "downloads" / "31337"
d.mkdir(parents=True)
(d / "a.png").write_bytes(b"x")
old = time.time() - 31 * 86400
os.utime(d, (old, old))
os.utime(d / "a.png", (old, old))
conn = h.conn()
try:
jira_api.downloads_tick(h, conn)
finally:
conn.close()
assert not d.exists()