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 sdlc_hub.core import api, db, files, queue, release, settings from sdlc_hub.core.client import HubClient, HubError from sdlc_hub.modules.jira import api as jira_api from sdlc_hub.modules.jira.commands import CommandError @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["agents"]["jira"]), "client": HubClient(base, tok["client"]), "base": base} srv.shutdown() srv.server_close() th.join(5) def _add_project(hub, add_project, *args, **kw): conn = hub["hub"].conn() try: return add_project(conn, *args, **kw) finally: conn.close() 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, add_project): _add_project(hub, add_project) 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"] assert [i["key"] for i in c.get("/issues", fact_status="В работе")["issues"]] == ["A-1", "A-2"] # параметр игнорируется assert all("fact_status" not in i for i in c.get("/issues")["issues"]) with pytest.raises(HubError) as e: # статуса по факту нет: поле — неизвестное (fact-status-removal) c.put("/issues/A-1/note", {"fact_status": "В работе", "work_kind": "Аналитика", "updated_by": "owner"}) assert e.value.status == 422 and "неизвестные поля заметки: fact_status" in e.value.detail assert c.get("/issues/A-1")["note"] is None # и work_kind не записан note = c.put("/issues/A-1/note", {"work_kind": "Аналитика", "updated_by": "owner"}) assert note["work_kind"] == "Аналитика" and note["updated_by"] == "owner" and "fact_status" not in note 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 in ("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 sdlc_hub.core.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": []}, {"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 @pytest.mark.parametrize("body, text", [({"base_jql": "project = AURORA"}, "базовая JQL собирается из проектов"), ({"board_id": 12}, "доска Jira — свойство проекта")]) def test_settings_put_rejects_jql_and_board(hub, body, text): c = hub["client"] v0 = c.get("/settings")["settings_version"] with pytest.raises(HubError) as e: c.put("/settings", body) assert e.value.status == 422 and text in str(e.value.detail) assert c.get("/settings")["settings_version"] == v0 def test_full_refresh_without_projects(hub): with pytest.raises(HubError) as e: hub["client"].post("/refresh", {"full": True}) assert e.value.status == 422 and "нет проектов" in str(e.value.detail) assert hub["client"].get("/batches")["batches"] == [] 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"]}) assert out["settings_version"] == v0 + 7 got = c.get("/settings") assert got["pacing"]["ensure_fields"] == 90 and got["port"] == 8800 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, add_project): """I-2(b): точечное обновление соседней задачи не опускает флаг.""" _add_project(hub, add_project) _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, add_project): """I-2(a): полный прогон по базовой JQL: не обновлённая им задача получает neighbor=1.""" _add_project(hub, add_project) 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, add_project): _add_project(hub, add_project) 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(release.module_dir(hub["hub"].release_root, "jira"), 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 = release.module_dir(hub["hub"].release_root, "jira") (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", {}) rd = release.module_dir(hub["hub"].release_root, "jira") (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 = release.module_dir(hub["hub"].release_root, "jira") 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 agent = 'jira'", (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_get_base_jql_from_projects(hub, add_project): c = hub["client"] assert c.get("/settings")["base_jql"] is None _add_project(hub, add_project) assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA")' 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"] is None 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", "OPTIONS"]) 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 agent = 'jira'", (old,)) conn.close() assert c.get("/agent")["online"] is False def test_tick_creates_auto_refresh_once(hub, add_project): """M-10 #4: тик при агенте на связи и пустой очереди ставит «Автообновление»; второй тик — нет (очередь занята).""" _add_project(hub, add_project) 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_agent_state_bad_attachment_422(hub): a, c = hub["agent"], hub["client"] with pytest.raises(HubError) as e: a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}, {"key": "Y-1", "summary": "Игрек", "labels": [], "attachments": [{"id": "7", "filename": "a", "size": -1}]}], "links": []}) assert e.value.status == 422 and e.value.error == "bad_state" assert "Y-1" in e.value.detail and "attachments[0].size" in e.value.detail with pytest.raises(HubError) as e: c.get("/issues/X-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_sweeps_downloads_once_a_day(hub): h = hub["hub"] old = time.time() - 31 * 86400 def put_old(name): d = h.db_path.parent / "downloads" / name d.mkdir(parents=True) (d / "a.png").write_bytes(b"x") os.utime(d / "a.png", (old, old)) os.utime(d, (old, old)) return d first = put_old("1") h.tick() assert not first.exists() and "downloads_at" in h.module_state["jira"] second = put_old("2") h.tick() assert second.exists() # второй тик в те же сутки — без уборки def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog, add_project): _add_project(hub, add_project) 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"] def test_create_project(hub): # сценарий «Новый проект» c = hub["client"] conn = hub["hub"].conn(); version = settings.get(conn, "settings_version"); conn.close() c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216}) out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": 5120, "author": "viewer"}) assert out["project"]["label"] == "STOCKS_DRRK" and out["project"]["board_type"] is None got = c.get("/projects")["projects"] assert [p["label"] for p in got] == ["AWPCA", "STOCKS_DRRK"] and got[1]["issues_count"] == 0 b = c.get(f"/batches/{out['batch_id']}") assert b["author"] == "viewer" and b["title"] == "Новый проект STOCKS_DRRK" and b["project"] is None assert b["commands"][0]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'} assert c.get("/settings")["settings_version"] == version # «Создание проекта не трогает версию настроек» assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")' def test_create_project_without_board(hub): c = hub["client"] out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) assert out["project"]["board_id"] is None and out["project"]["board_type"] is None b = c.get(f"/batches/{out['batch_id']}") assert b["author"] == "api" @pytest.mark.parametrize("body, status, field", [ ({"label": "AWPCA", "name": "x"}, 409, "label"), ({"label": "awpca", "name": "x"}, 409, "label"), ({"label": "STOCKS DRRK", "name": "x"}, 422, "label"), ({"label": "A\\B", "name": "x"}, 422, "label"), ({"label": "A\nB", "name": "x"}, 422, "label"), ({"label": "A\tB", "name": "x"}, 422, "label"), ({"label": "STOCKS_DRRK", "name": " "}, 422, "name"), ({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"), ({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None), ({"label": "STOCKS_DRRK", "name": "x", "author": 0}, 422, None), ({"label": "STOCKS_DRRK", "name": "x", "author": False}, 422, None), ({"label": "STOCKS_DRRK", "name": "x", "author": []}, 422, None), ({"label": "STOCKS_DRRK", "name": "x", "author": {}}, 422, None), ]) def test_create_project_errors(hub, body, status, field): c = hub["client"] c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) before = len(c.get("/batches")["batches"]) with pytest.raises(HubError) as e: c.post("/projects", body) assert e.value.status == status and (field is None or e.value.detail.startswith(field)) assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before @pytest.mark.parametrize("author", [None, ""]) def test_create_project_empty_author_is_api(hub, author): c = hub["client"] out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "x", "author": author}) assert c.get(f"/batches/{out['batch_id']}")["author"] == "api" def test_create_project_batch_error_rolls_back(hub, monkeypatch): # T3.2 def boom(*a, **k): raise CommandError("не ставится") monkeypatch.setattr(queue, "create_batch", boom) c = hub["client"] with pytest.raises(HubError) as e: c.post("/projects", {"label": "STOCKS_DRRK", "name": "x"}) assert e.value.status == 422 and e.value.error == "bad_batch" assert c.get("/projects")["projects"] == [] def test_projects_need_client_token(hub): with pytest.raises(HubError) as e: hub["agent"].get("/projects") assert e.value.status == 401 with pytest.raises(HubError) as e: hub["agent"].post("/projects", {"label": "STOCKS_DRRK", "name": "x"}) assert e.value.status == 401 def test_board_and_issues_by_project(hub): c, a = hub["client"], hub["agent"] c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) a.post("/agent/state", {"issues": [ {"key": "A-1", "summary": "Т1", "labels": ["STOCKS_DRRK"]}, {"key": "A-2", "summary": "Т2", "labels": ["AWPCA"]}, {"key": "A-3", "summary": "Т3", "labels": ["AWPCA"]}], "links": [{"source_key": "A-1", "target_key": "A-2", "link_type": "L"}]}) snap = c.get("/board?project=STOCKS_DRRK") assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [("A-1", False), ("A-2", True)] assert [(l["source"], l["target"]) for l in snap["links"]] == [("A-1", "A-2")] assert snap["last_sync"] == c.get("/board")["last_sync"] assert len(c.get("/board")["issues"]) == 3 assert [i["key"] for i in c.get("/issues?project=STOCKS_DRRK&neighbor=1")["issues"]] == ["A-2"] for path in ("/issues?project=NOPE", "/board?project=NOPE"): with pytest.raises(HubError) as e: c.get(path) assert e.value.status == 404 and "project NOPE" in e.value.detail def test_project_batches_api(hub, add_project): # #61, Task 5 conn = hub["hub"].conn() try: add_project(conn) add_project(conn, "STOCKS_DRRK", board_id=None) finally: conn.close() hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "А", "labels": ["AWPCA"]}, {"key": "A-2", "summary": "С", "labels": ["STOCKS_DRRK"]}], "links": []}) c = hub["client"] cmd = lambda key: [{"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23"}}] with pytest.raises(HubError) as e: c.post("/batches", {"title": "x", "project": "STOCKS_DRRK", "commands": cmd("A-1")}) assert e.value.status == 422 and "не в проекте" in e.value.detail with pytest.raises(HubError) as e: c.post("/batches", {"title": "x", "project": 5, "commands": cmd("A-2")}) assert e.value.status == 422 with pytest.raises(HubError) as e: c.post("/batches", {"title": "x", "project": "", "commands": cmd("A-2")}) assert e.value.status == 422 and "project: пустая строка" in e.value.detail mine = c.post("/batches", {"title": "x", "project": "STOCKS_DRRK", "commands": cmd("A-2")}) c.post("/batches", {"title": "y", "commands": cmd("A-1")}) assert [b["id"] for b in c.get("/batches", project="STOCKS_DRRK")["batches"]] == [mine["id"]] assert c.get(f"/batches/{mine['id']}")["project"] == "STOCKS_DRRK" own = c.post("/batches", {"title": "z", "project": "AWPCA", "commands": cmd("A-1")}) cid = c.get(f"/batches/{own['id']}")["commands"][0]["id"] before = len(c.get("/batches", limit=50)["batches"]) with pytest.raises(HubError) as e: c.post(f"/commands/{cid}/retry", {"project": "STOCKS_DRRK"}) assert e.value.status == 422 and f"команда #{cid} не из проекта STOCKS_DRRK" in e.value.detail with pytest.raises(HubError) as e: c.post(f"/commands/{cid}/retry", {"project": 5}) assert e.value.status == 422 with pytest.raises(HubError) as e: c.post(f"/commands/{cid}/retry", {"project": ""}) assert e.value.status == 422 and "project: пустая строка" in e.value.detail assert len(c.get("/batches", limit=50)["batches"]) == before r = c.post(f"/commands/{cid}/retry", {}) assert c.get(f"/batches/{r['id']}")["project"] == "AWPCA" def test_agent_command_refresh_has_boards(hub, add_project): # #61, Task 6 _add_project(hub, add_project) b = hub["client"].post("/batches", {"title": "r", "commands": [{"type": "refresh", "params": {"keys": ["A-1"]}}]}) cid = hub["client"].get(f"/batches/{b['id']}")["commands"][0]["id"] got = hub["agent"].get(f"/agent/commands/{cid}") assert got["params"] == {"keys": ["A-1"], "boards": [{"id": 4216, "type": "scrum"}]} assert "boards" not in hub["client"].get(f"/batches/{b['id']}")["commands"][0]["params"] def test_board_type_round_trip(hub): # сценарий «Тип доски определён» c, a = hub["client"], hub["agent"] c.post("/projects", {"label": "STOCKS_DRRK", "name": "Остатки ДРРК", "board_id": 5120}) cmd = a.post("/agent/claim", {"agent_version": 12, "settings_version": 1})["command"] # refresh от создания проекта assert cmd["type"] == "refresh" and {"id": 5120, "type": None} in cmd["params"]["boards"] a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"boards": {"5120": "kanban"}}}) assert [p["board_type"] for p in c.get("/projects")["projects"] if p["label"] == "STOCKS_DRRK"] == ["kanban"] a.post("/agent/state", {"issues": [{"key": "A-5", "summary": "С", "labels": ["STOCKS_DRRK"]}], "links": []}) with pytest.raises(HubError) as e: c.post("/batches", {"title": "s", "commands": [{"type": "ensure_sprint", "params": {"key": "A-5", "sprint": "С2"}}]}) assert e.value.status == 422 and "канбан-доска" in e.value.detail HISTORY_ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров", "field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001", "to_string": "Готово"} def test_agent_history_route(hub): assert hub["agent"].post("/agent/history", {"command_id": None, "changes": [HISTORY_ITEM]}) == {"accepted": 1, "duplicates": 0} items = [HISTORY_ITEM, {**HISTORY_ITEM, "seq": 1}, {**HISTORY_ITEM, "seq": 2}] assert hub["agent"].post("/agent/history", {"changes": items}) == {"accepted": 2, "duplicates": 1} with pytest.raises(HubError) as e: hub["client"].post("/agent/history", {"changes": items}) assert e.value.status == 401 with pytest.raises(HubError) as e: hub["agent"].post("/agent/history", {"changes": [{**HISTORY_ITEM, "seq": 7}, {**HISTORY_ITEM, "seq": "0"}]}) assert e.value.status == 422 and e.value.error == "bad_history" and "changes[1].seq" in e.value.detail conn = hub["hub"].conn() try: assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 3 finally: conn.close() def test_sync_runs_history_at(hub): conn = hub["hub"].conn() try: conn.execute("INSERT INTO sync_run(started_at, kind, history_at) VALUES ('2026-10-22T09:00:00+00:00', 'jql', " "'2026-10-22T09:02:00+00:00')") finally: conn.close() assert hub["client"].get("/sync-runs")["runs"][0]["history_at"] == "2026-10-22T09:02:00+00:00" def test_changes_route(hub, add_project): conn = hub["hub"].conn() try: add_project(conn) finally: conn.close() hub["agent"].post("/agent/history", {"changes": [{**HISTORY_ITEM, "created": "2026-10-22T11:00:00Z"}]}) got = hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T13:00:00+03:00", kinds="status") assert got["since"] == "2026-10-22T10:00:00+00:00" and got["until"] == "2026-10-22T10:00:00+00:00" got = hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T12:00:00Z") assert [e["key"] for e in got["changes"]] == ["X"] and got["truncated"] is False assert hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T12:00:00Z", project="AWPCA")["changes"] == [] # X без метки проекта default = hub["client"].get("/changes") assert db.parse(default["until"]) - db.parse(default["since"]) == timedelta(hours=24) @pytest.mark.parametrize("params, status, name", [ ({"since": "вчера"}, 422, "since"), ({"until": "x"}, 422, "until"), ({"since": "2026-10-22T12:00:00Z", "until": "2026-10-22T10:00:00Z"}, 422, "since"), ({"kinds": "foo"}, 422, "kinds"), ({"limit": "0"}, 422, "limit"), ({"limit": "2001"}, 422, "limit"), ({"limit": "x"}, 422, "limit"), ({"project": "NOPE"}, 404, None), ({"since": "2020-01-01T09:00"}, 422, "since"), ({"until": "2026-10-22"}, 422, "until"), # M-2: без смещения ]) def test_changes_route_bad_params(hub, params, status, name): with pytest.raises(HubError) as e: hub["client"].get("/changes", **params) assert e.value.status == status if name: assert e.value.error == "bad_request" and name in e.value.detail def test_issue_has_attachments(hub): a, c = hub["agent"], hub["client"] c.post("/batches", {"title": "x", "author": "claude", "commands": [{"type": "refresh", "params": {"keys": ["A-1"]}}]}) cmd = a.post("/agent/claim", {})["command"] a.post("/agent/state", {"command_id": cmd["id"], "page": 1, "last_page": True, "links": [], "issues": [ {"key": "A-1", "summary": "Т", "labels": [], "description": "!a.png!", "created": "2026-10-10T10:00:00.000+0300", "attachments": [{"id": "7", "filename": "a.png", "size": 10, "created": "2026-10-10T10:00:00.000+0300"}]}]}) got = c.get("/issues/A-1") assert got["attachments"][0]["attachment_id"] == "7" and got["attachments"][0]["state"] == "none" assert got["description_attachment_ids"] == ["7"] # --- вложения из Jira по запросу (#83) ---------------------------------------------- ATT_NAME = "скрин 1.png" def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME): conn = hub["hub"].conn() try: from sdlc_hub.modules.jira import mirror mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ {"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], []) finally: conn.close() def _content(hub, path): req = urllib.request.Request(hub["base"] + path, headers={"Authorization": "Bearer " + hub["client"].token}) return urllib.request.urlopen(req) def _fetch_done(hub, data=b"abc"): """Запрос скачивания, claim агентом, загрузка файла и отчёт done.""" out = hub["client"].post("/attachments/31337/fetch", {"author": "web"}) cmd = hub["agent"].post("/agent/claim", {})["command"] up = hub["agent"].post("/agent/files", {"content": _b64(data)}) hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": up}) return out, cmd, up def test_agent_files_post(hub): a = hub["agent"] out = a.post("/agent/files", {"content": _b64(b"abc")}) assert out == {"file": hashlib.sha256(b"abc").hexdigest(), "size": 3} with pytest.raises(HubError) as e: hub["client"].post("/agent/files", {"content": _b64(b"abc")}) assert e.value.status == 401 assert a.post("/agent/files", {"content": ""}) == {"file": hashlib.sha256(b"").hexdigest(), "size": 0} with pytest.raises(HubError) as e: a.post("/agent/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))}) assert e.value.status == 413 and e.value.error == "too_large" with pytest.raises(HubError) as e: a.post("/agent/files", {"content": "***"}) assert e.value.status == 400 def test_attachment_get_404(hub): for method, path in (("get", "/attachments/1"), ("post", "/attachments/1/fetch"), ("get", "/attachments/1/content")): with pytest.raises(HubError) as e: getattr(hub["client"], method)(path) assert e.value.status == 404 and e.value.error == "no_attachment" def test_attachment_fetch_flow(hub): _seed_attachment(hub) out, cmd, up = _fetch_done(hub) assert out["state"] == "fetching" and out["command_id"] and out["batch_id"] assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2 got = hub["client"].get("/attachments/31337") assert got["state"] == "ready" and got["filename"] == ATT_NAME and got["key"] == "A-1" resp = _content(hub, "/attachments/31337/content") assert resp.status == 200 and resp.read() == b"abc" assert resp.headers["Content-Type"] == "image/png" assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png" assert resp.headers["X-Content-Type-Options"] == "nosniff" assert resp.headers["Content-Length"] == "3" def test_attachment_content_updates_mtime(hub): _seed_attachment(hub) _, _, up = _fetch_done(hub) p = hub["hub"].files_dir / up["file"] old = time.time() - 10 * 86400 os.utime(p, (old, old)) _content(hub, "/attachments/31337/content").read() assert p.stat().st_mtime > time.time() - 60 def test_attachment_content_special_name(hub): name = "a:b/c d\u00e9.txt" _seed_attachment(hub, name=name, mime="text/plain") _fetch_done(hub) resp = _content(hub, "/attachments/31337/content") assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''a%3Ab%2Fc%20d%C3%A9.txt" def test_attachment_content_not_fetched(hub): _seed_attachment(hub) with pytest.raises(HubError) as e: hub["client"].get("/attachments/31337/content") assert e.value.status == 404 and e.value.error == "not_fetched" def test_attachment_content_bad_mime(hub): _seed_attachment(hub, mime="weird") _fetch_done(hub) assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream" def test_attachment_content_non_ascii_mime(hub): """Кириллический mime_type не должен ронять send_header (UnicodeEncodeError → 500): \w в MIME_RE — только ASCII.""" _seed_attachment(hub, mime="текст/простой") _fetch_done(hub) assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream" def test_attachment_fetch_twice_same_command(hub): _seed_attachment(hub) a = hub["client"].post("/attachments/31337/fetch", {}) b = hub["client"].post("/attachments/31337/fetch", {}) assert a["command_id"] == b["command_id"] and b["state"] == "fetching" def test_attachment_fetch_too_large_413(hub): _seed_attachment(hub, size=25_000_000) with pytest.raises(HubError) as e: hub["client"].post("/attachments/31337/fetch", {}) assert e.value.status == 413 and e.value.error == "too_large" assert e.value.detail == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira" assert hub["client"].get("/batches")["batches"] == [] def test_batches_post_fetch_too_large_413(hub): """M-2: POST /batches с fetch_attachment > 10 МБ — 413 too_large, не 422.""" _seed_attachment(hub, size=25_000_000) with pytest.raises(HubError) as e: hub["client"].post("/batches", {"title": "x", "commands": [ {"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": "31337"}}]}) assert e.value.status == 413 and e.value.error == "too_large" assert hub["client"].get("/batches")["batches"] == [] def test_command_retry_fetch_too_large_413(hub): """M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large.""" _seed_attachment(hub) st = hub["client"].post("/attachments/31337/fetch", {}) _seed_attachment(hub, size=25_000_000) with pytest.raises(HubError) as e: hub["client"].post(f"/commands/{st['command_id']}/retry", {"author": "claude"}) assert e.value.status == 413 and e.value.error == "too_large" def test_attachment_fetch_agent_too_old_409(hub): _seed_attachment(hub) hub["agent"].post("/agent/claim", {"agent_version": 13}) with pytest.raises(HubError) as e: hub["client"].post("/attachments/31337/fetch", {}) assert e.value.status == 409 and e.value.error == "agent_too_old" assert e.value.detail == "агент v13 не умеет скачивать вложения — нужен v14" def test_sweep_files_downloads(hub): h = hub["hub"] d = h.db_path.parent / "downloads" / "31337" d.mkdir(parents=True) (d / "a.png").write_bytes(b"x") old = time.time() - 31 * 86400 os.utime(d, (old, old)) os.utime(d / "a.png", (old, old)) conn = h.conn() try: jira_api.downloads_tick(h, conn) finally: conn.close() assert not d.exists() @pytest.mark.parametrize("over, detail", [ ({"sprint_ids": None}, "A-1: sprint_ids"), ({"sprint_ids": ["1"]}, "A-1: sprint_ids"), ({"sprint_ids": [True]}, "A-1: sprint_ids"), ({"story_points": "3"}, "A-1: story_points"), ({"story_points": True}, "A-1: story_points"), ({"story_points": float("nan")}, "A-1: story_points"), ({"story_points": float("inf")}, "A-1: story_points"), # Review Focus 5 ({"rank": 5}, "A-1: rank"), ]) def test_agent_state_bad_agile_fields_422(hub, over, detail): with pytest.raises(HubError) as e: hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": [], **over}], "links": []}) assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail assert hub["client"].get("/issues")["issues"] == [] # пачка не применена @pytest.mark.parametrize("boards, detail", [ ("x", "boards — список досок"), ([{"id": "4216"}], "boards[0].id"), ([{"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "sprints": [{"id": 1, "name": "S", "state": "closing"}]}], "доска 4216: sprints[0].state — active, future или closed"), ([{"id": 4216, "type": "sprint"}], "доска 4216: type"), ([{"id": 4216, "type": "kanban", "kanban_backlog": "да"}], "доска 4216: kanban_backlog"), ]) def test_agent_state_bad_boards_422(hub, boards, detail): with pytest.raises(HubError) as e: hub["agent"].post("/agent/state", {"issues": [], "links": [], "boards": boards}) assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail c = hub["hub"].conn() try: assert c.execute("SELECT COUNT(*) FROM jira_board").fetchone()[0] == 0 finally: c.close() def test_agent_state_empty_batch_with_boards(hub): b = {"id": 4216, "type": "scrum", "columns": [{"name": "К", "statuses": ["Backlog"]}], "kanban_backlog": None, "sprints": [{"id": 101, "name": "S1", "state": "active", "start_date": None, "end_date": None, "complete_date": None, "goal": None}]} assert hub["agent"].post("/agent/state", {"issues": [], "links": [], "boards": [b]}) == {"issues": 0, "links": 0} c = hub["hub"].conn() try: assert c.execute("SELECT type FROM jira_board WHERE board_id = 4216").fetchone()[0] == "scrum" assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1 finally: c.close()