Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
224 lines
10 KiB
Python
224 lines
10 KiB
Python
import json
|
||
import threading
|
||
|
||
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"] is None # одна в полёте, долгий запрос выключен
|
||
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_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
|