sdlc-hub/tests/test_api.py
Andrey Epifancev e92e7711d6 feat(web): доска на данных хаба — вход по токену, GET /board, «Перечитать» (#17)
Встроенная выгрузка от 11.10.2026 удалена; CSV и JSON — загрузка файла.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:02:48 +04:00

395 lines
19 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 json
import threading
import urllib.error
import urllib.request
import pytest
from jira_hub import api, db, settings
from jira_hub.client import HubClient, HubError
@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["agent"]), "client": HubClient(base, tok["client"]), "base": base}
srv.shutdown()
srv.server_close()
th.join(5)
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_Связанные"}]
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):
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 = 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.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
import urllib.request, urllib.error
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):
def broken():
raise OSError("диск недоступен")
monkeypatch.setattr(hub["hub"], "conn", broken)
hub["hub"].tick() # не бросает
monkeypatch.undo()
hub["hub"].tick()
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": []}, {"base_jql": 5},
{"labels": {}}, {"initialized": 1},
{"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
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"], "board_id": 12})
assert out["settings_version"] == v0 + 8
got = c.get("/settings")
assert got["pacing"]["ensure_fields"] == 90 and got["port"] == 8800 and got["board_id"] == 12
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):
"""I-2(b): точечное обновление соседней задачи не опускает флаг."""
_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):
"""I-2(a): полный прогон по базовой JQL: не обновлённая им задача получает neighbor=1."""
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):
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 _raw_get(base, path):
with urllib.request.urlopen(base + path, timeout=10) as resp:
return resp.status, resp.headers, resp.read().decode("utf-8")
def test_page_served_without_token(hub):
hub["agent"].post("/agent/state", {"issues": [{"key": "ZZTEST-9001", "summary": "тема из зеркала", "labels": []}],
"links": []})
for path in ("/", "/links"):
status, headers, body = _raw_get(hub["base"], path)
assert status == 200
assert headers["Content-Type"] == "text/html; charset=utf-8"
assert headers["Cache-Control"] == "no-cache"
assert body.lstrip().lower().startswith("<!doctype html>")
assert "ZZTEST-9001" not in body and "тема из зеркала" not in body
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(hub["base"] + "/links/x", timeout=10)
assert e.value.code == 404
def test_page_reads_hub_and_has_no_embedded_export():
html = api.PAGE.read_text(encoding="utf-8")
assert "DEFAULT_CSV" not in html and "Ключ проблемы,Тема" not in html # встроенной выгрузки нет
assert '"jira-hub-token"' in html and 'hubFetch("/board")' in html
assert "Нет в зеркале" in html