745 lines
37 KiB
Python
745 lines
37 KiB
Python
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 import api, db, files, queue, release, 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_Связанные", "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):
|
||
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
|
||
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": []}, {"base_jql": 5},
|
||
{"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
|
||
|
||
|
||
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 _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(hub["hub"].release_dir, 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 = hub["hub"].release_dir
|
||
rd.mkdir()
|
||
(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", None)
|
||
rd = hub["hub"].release_dir
|
||
rd.mkdir()
|
||
(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 = hub["hub"].release_dir
|
||
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 id = 1", (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_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"] == "project = AURORA AND labels = AWPCA"
|
||
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", "PATCH"])
|
||
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 id = 1", (old,))
|
||
conn.close()
|
||
assert c.get("/agent")["online"] is False
|
||
|
||
|
||
def test_tick_creates_auto_refresh_once(hub):
|
||
"""M-10 #4: тик при агенте на связи и пустой очереди ставит «Автообновление»; второй тик — нет (очередь занята)."""
|
||
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_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_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog):
|
||
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"]
|