sdlc-hub/tests/test_relay.py
2026-10-13 00:39:36 +04:00

244 lines
8.8 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 time
from datetime import datetime, timedelta, timezone
import pytest
from jira_hub import api, relay, settings
from jira_hub.client import HubClient
@pytest.fixture
def env(tmp_path):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
h.claim_wait = 0
conn = h.conn()
root = tmp_path / "bridge"
root.mkdir()
settings.set_(conn, "relay_dir", str(root))
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()
r = relay.Relay(h, srv.server_port)
client = HubClient(f"http://127.0.0.1:{srv.server_port}", tok["client"])
yield {"hub": h, "root": root, "relay": r, "agent": "Bearer " + tok["agent"], "client": client,
"port": srv.server_port}
srv.shutdown()
srv.server_close()
th.join(5)
def deadline(seconds):
return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat()
def put_request(root, rid, method, path, auth, body=None, seconds=60, **extra):
req = {"id": rid, "method": method, "path": path, "body": body, "authorization": auth,
"deadline": deadline(seconds), **extra}
relay.write_atomic(root / "req", f"{rid}.json", req)
def response(root, rid):
p = root / "resp" / f"{rid}.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
def run_once(r):
r.step()
r.join()
def test_request_roundtrip(env):
r, root = env["relay"], env["root"]
r.step() # подготовка: req/, resp/, work/
put_request(root, "a1", "GET", "/agent/settings", env["agent"])
run_once(r)
resp = response(root, "a1")
assert resp["status"] == 200 and resp["id"] == "a1"
assert json.loads(resp["body"])["project_key"] == "AURORA"
assert not list((root / "work").iterdir()) and not list((root / "req").iterdir())
def test_status_passes_through(env):
r, root = env["relay"], env["root"]
r.step()
put_request(root, "u1", "GET", "/issues", env["agent"]) # агентский токен на клиентский маршрут
put_request(root, "u2", "GET", "/agent/commands/999", env["agent"])
run_once(r)
assert response(root, "u1")["status"] == 401
assert response(root, "u2")["status"] == 404
def test_expired_and_short_claim_dropped(env):
r, root, c = env["relay"], env["root"], env["client"]
b = c.post("/batches", {"title": "t", "author": "test",
"commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
r.step()
put_request(root, "c1", "POST", "/agent/claim", env["agent"], body={}, seconds=20)
put_request(root, "s1", "GET", "/agent/settings", env["agent"], seconds=-1)
run_once(r)
assert response(root, "c1") is None and response(root, "s1") is None
statuses = [x["status"] for x in c.get(f"/batches/{b['id']}")["commands"]]
assert statuses[0] == "queued"
assert not list((root / "work").iterdir())
def test_claim_with_enough_time_is_forwarded(env):
r, root = env["relay"], env["root"]
r.step()
put_request(root, "c2", "POST", "/agent/claim", env["agent"], body={"agent_version": 2}, seconds=50)
run_once(r)
assert response(root, "c2")["status"] == 200
def test_long_claim_does_not_block_other_requests(env):
env["hub"].claim_wait = 3
r, root = env["relay"], env["root"]
r.step()
put_request(root, "c3", "POST", "/agent/claim", env["agent"], body={}, seconds=60)
r.step()
put_request(root, "s3", "GET", "/agent/settings", env["agent"])
t0 = time.monotonic()
while response(root, "s3") is None and time.monotonic() - t0 < 2.5:
r.step()
time.sleep(0.05)
assert response(root, "s3")["status"] == 200
assert response(root, "c3") is None # claim ещё ждёт
r.join()
assert response(root, "c3")["status"] == 200
def test_unreachable_hub_gives_502(env):
r = relay.Relay(env["hub"], 1) # на порту 1 никто не слушает
root = env["root"]
r.step()
put_request(root, "p1", "GET", "/agent/settings", env["agent"])
run_once(r)
resp = response(root, "p1")
assert resp["status"] == 502 and "хаб недоступен" in resp["body"]
def test_dotfiles_ignored_and_bad_json_dropped(env, monkeypatch, capsys):
monkeypatch.setattr(relay, "BAD_JSON_WAIT", 0.2)
r, root = env["relay"], env["root"]
r.step()
(root / "req" / ".x1.json.tmp").write_text("{}", encoding="utf-8")
(root / "req" / "bad.json").write_text("{не json", encoding="utf-8")
run_once(r)
assert (root / "req" / ".x1.json.tmp").exists()
assert response(root, "bad") is None and not list((root / "work").iterdir())
assert "нечитаемый запрос bad.json" in capsys.readouterr().out
def test_bad_request_fields_get_400(env):
r, root = env["relay"], env["root"]
r.step()
put_request(root, "b1", "GET", "agent/settings", env["agent"]) # путь без /
put_request(root, "b2", "DELETE", "/agent/settings", env["agent"]) # метод не из списка
put_request(root, "b3", "GET", "/agent/settings", "Bearer x\r\nX-Evil: 1") # перевод строки в заголовке
run_once(r)
for rid in ("b1", "b2", "b3"):
assert response(root, rid)["status"] == 400, rid
def test_missing_deadline_is_dropped(env):
r, root = env["relay"], env["root"]
r.step()
relay.write_atomic(root / "req", "d1.json", {"id": "d1", "method": "GET", "path": "/agent/settings",
"authorization": env["agent"]})
relay.write_atomic(root / "req", "d2.json", {"id": "d2", "method": "GET", "path": "/agent/settings",
"authorization": env["agent"], "deadline": "завтра"})
run_once(r)
assert response(root, "d1") is None and response(root, "d2") is None
def test_vanished_request_is_skipped(env, capsys):
r, root = env["relay"], env["root"]
r.step()
put_request(root, "v1", "GET", "/agent/settings", env["agent"])
orig = os.rename
def vanish(src, dst):
os.remove(src) # агент удалил свой запрос раньше реле
return orig(src, dst)
os.rename = vanish
try:
run_once(r)
finally:
os.rename = orig
assert response(root, "v1") is None
assert "Traceback" not in capsys.readouterr().err
def test_work_cleared_on_prepare(env, capsys):
r, root = env["relay"], env["root"]
(root / "work").mkdir()
(root / "work" / "old.json").write_text("{}", encoding="utf-8")
r.step()
assert not (root / "work" / "old.json").exists()
assert "old.json" in capsys.readouterr().out
def test_missing_dir_warns_once_then_works(env, capsys):
r, root = env["relay"], env["root"]
gone = root.parent / "bridge-later"
conn = env["hub"].conn()
settings.set_(conn, "relay_dir", str(gone))
conn.close()
for _ in range(5):
r.step()
out = capsys.readouterr().out
assert out.count("нет — жду") == 1
gone.mkdir()
r.step()
assert (gone / "req").is_dir() and (gone / "resp").is_dir() and (gone / "work").is_dir()
put_request(gone, "m1", "GET", "/agent/settings", env["agent"])
run_once(r)
assert response(gone, "m1")["status"] == 200
def test_disabled_and_reenabled_without_restart(env, monkeypatch):
r, root = env["relay"], env["root"]
conn = env["hub"].conn()
settings.set_(conn, "relay_dir", None)
conn.close()
r.step()
assert r.root is None and not (root / "req").exists()
monkeypatch.setattr(relay, "SETTINGS_EVERY", 0)
conn = env["hub"].conn()
settings.set_(conn, "relay_dir", str(root))
conn.close()
r.step()
assert r.root == root and (root / "req").is_dir()
def test_cleanup_by_age(env):
r, root = env["relay"], env["root"]
r.step()
old, fresh = root / "resp" / "old.json", root / "resp" / "fresh.json"
tmp_old = root / "resp" / ".t.json.tmp"
for p in (old, fresh, tmp_old):
p.write_text("{}", encoding="utf-8")
now = time.time()
os.utime(old, (now - 700, now - 700))
os.utime(tmp_old, (now - 700, now - 700))
stale_req = root / "req" / ".q.json.tmp"
stale_req.write_text("{}", encoding="utf-8")
os.utime(stale_req, (now - 700, now - 700))
r.cleanup(now)
assert not old.exists() and not tmp_old.exists() and not stale_req.exists()
assert fresh.exists()
def test_log_line(env, capsys):
r, root = env["relay"], env["root"]
r.step()
put_request(root, "l1", "GET", "/agent/settings", env["agent"])
run_once(r)
assert "relay GET /agent/settings 200" in capsys.readouterr().out