sdlc-hub/tests/test_api.py
2026-10-15 13:04:55 +04:00

601 lines
30 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 os
import threading
import urllib.error
import urllib.request
from datetime import timedelta
import pytest
from jira_hub import api, db, 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},
{"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"] == "Эпик"