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("") 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 assert 'author: "viewer"' in html and "/batches/" in html assert "KEY_RE.test(l.source) && KEY_RE.test(l.target)" in html # I-1: ключи связей из зеркала идут в разметку карточек