import asyncio import json import re import threading from types import SimpleNamespace import pytest from jira_hub import api, mcp_server, settings from jira_hub.client import HubClient @pytest.fixture def live(tmp_path, monkeypatch): h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") h.claim_wait = 0 conn = h.conn() tok = settings.tokens(conn) conn.close() srv = api.make_server(h, "127.0.0.1", 0) threading.Thread(target=srv.serve_forever, daemon=True).start() base = f"http://127.0.0.1:{srv.server_port}" monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"])) monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) yield {"agent": HubClient(base, tok["agent"])} srv.shutdown() srv.server_close() def test_offline_message(monkeypatch): monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) assert mcp_server.issues() == mcp_server.OFFLINE assert mcp_server.agent_status() == mcp_server.OFFLINE def test_tools_roundtrip(live): live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23", "assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []}) out = mcp_server.issues(sprint="Спринт 2") assert "A-1" in out and "Тема раз" in out and "ivanov" in out assert "В работе" in mcp_server.notes_set("A-1", fact_status="В работе") detail = mcp_server.issue("A-1") assert "fact_status: В работе" in detail and "Готово" in detail created = mcp_server.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}]) batch_id = int(created.split("#")[1].split()[0]) assert "2 команд" in created and "мин" in created bad = mcp_server.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}]) assert bad.startswith("ошибка хаба 422") cmd = live["agent"].post("/agent/claim", {})["command"] live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"}) tail = live["agent"].post("/agent/claim", {})["command"] live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"}) waited = asyncio.run(mcp_server.batch_wait(batch_id, timeout_minutes=1)) assert "done" in waited and "failed" in waited and "HTTP 400" in waited assert f"#{batch_id}" in mcp_server.batches(limit=5) assert "Повтор команды" in mcp_server.command_retry(cmd["id"]) assert "cancelled" in mcp_server.batch_cancel(batch_id + 1) r = asyncio.run(mcp_server.refresh(full=True)) assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r) assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(mcp_server.refresh(keys=["A-1"], wait=False))) assert "онлайн" in mcp_server.agent_status() assert "120" in mcp_server.settings_get("pacing") assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1})) assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422") def test_formatters(): assert mcp_server.fmt_issues([]) == "задач нет" text = mcp_server.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None, "sprint": None, "fact_status": None}]) assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text assert "очередь" in mcp_server.fmt_agent({"online": False, "agent_version": None, "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None, "settings_version": None}) text = mcp_server.fmt_agent({"online": True, "agent_version": "2", "current_command_id": 40, "rate_limited_24h": 0, "redelivered": [{"id": 40, "type": "ensure_fields", "claimed_at": "2026-10-13T10:00:00+00:00", "deliveries": 2}], "warning": None, "last_seen_at": "2026-10-13T10:00:00+00:00", "settings_version": 3}) assert "онлайн" in text and "#40" in text and "выдач 2" in text def test_settings_set_bad_json(live): assert mcp_server.settings_set("pacing", "{не json").startswith("значение не JSON") def _batch(status): return {"id": 7, "title": "т", "author": "claude", "created_at": "2026-10-11", "status": status, "remaining_seconds": 0, "counts": {status: 1}, "commands": []} class FakeClient: def __init__(self, statuses, runs=None): self.statuses, self.calls, self.runs = list(statuses), 0, runs def get(self, path, **params): if path == "/sync-runs": return {"runs": self.runs} self.calls += 1 return _batch(self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0]) def _fake_time(monkeypatch, step=1.0): clock = {"now": 0.0, "sleeps": 0, "deadlines": []} def monotonic(): clock["now"] += step return clock["now"] async def sleep(_): clock["sleeps"] += 1 monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic)) monkeypatch.setattr(mcp_server, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread)) return clock def test_batch_wait_polls_until_done(monkeypatch): fake = FakeClient(["running", "running", "done"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) clock = _fake_time(monkeypatch) out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=10)) assert out == mcp_server.fmt_batch(_batch("done")) and "таймаут" not in out assert fake.calls == 3 and clock["sleeps"] == 2 def test_batch_wait_timeout(monkeypatch): fake = FakeClient(["running"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=1)) assert "таймаут ожидания" in out assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1 @pytest.mark.parametrize("asked,minutes", [(0, 1), (1000, 60)]) def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes): fake = FakeClient(["running"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута asyncio.run(mcp_server.batch_wait(7, timeout_minutes=asked)) # первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна assert fake.calls == minutes def test_client_created_once_per_wait(monkeypatch): fake, made = FakeClient(["running", "running", "done"]), [] monkeypatch.setattr(mcp_server, "_client", lambda: made.append(1) or fake) _fake_time(monkeypatch) asyncio.run(mcp_server.batch_wait(7)) assert len(made) == 1 def test_sync_log_content(monkeypatch): runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None, "issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}] monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs)) assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1" def test_async_tools_are_coroutines_and_registered(): assert asyncio.iscoroutinefunction(mcp_server.batch_wait) and asyncio.iscoroutinefunction(mcp_server.refresh) tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())} assert {"batch_wait", "refresh"} <= set(tools) assert tools["refresh"].inputSchema["properties"]["wait"]["default"] is False def test_async_guard_offline(monkeypatch): monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) assert asyncio.run(mcp_server.batch_wait(1)) == mcp_server.OFFLINE assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE def test_batch_wait_does_not_block_event_loop(monkeypatch): """Пока идёт ожидание, другая задача event loop продолжает выполняться.""" fake = FakeClient(["running", "running", "done"]) monkeypatch.setattr(mcp_server, "_client", lambda: fake) monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.01) async def scenario(): ticks = [] async def ticker(): for _ in range(5): ticks.append(1) await asyncio.sleep(0.005) out, _ = await asyncio.gather(mcp_server.batch_wait(7), ticker()) return out, ticks out, ticks = asyncio.run(scenario()) assert "done" in out and len(ticks) == 5 def test_fmt_batch_warns_about_claimed(): b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running", "remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [], "still_claimed": [{"id": 30, "type": "ensure_fields"}]} text = mcp_server.fmt_batch(b) assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text def test_batch_create_mentions_labels(): doc = mcp_server.batch_create.__doc__ or "" assert "labels" in doc and "AWPCA" in doc def test_fmt_agent_update_lines(): base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3} text = mcp_server.fmt_agent({**base, "release": {"version": 7, "sha256": "s", "published_at": "2026-10-14T19:00:00+00:00"}, "update_state": {"state": "rolled_back", "from_version": 6, "to_version": 7}, "launcher_version": 1, "update_hint": "откат с v7 на v6: код 1 через 3 с"}) assert "публикация: v7 от 2026-10-14T19:00:00+00:00" in text assert "обновление: rolled_back v6 → v7" in text assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None}) assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text def test_fmt_agent_launcher_unknown_after_restart(): """M-2 #25, решение 8: agent_reported = false — «неизвестно», а не «без запускателя»; поля нет — прежний текст.""" base = {"online": True, "agent_version": "9", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3, "release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"}, "update_state": None, "launcher_version": None, "update_hint": None} text = mcp_server.fmt_agent({**base, "agent_reported": False}) assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text assert "без запускателя" not in text and "агент запущен без run.py" not in text assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True}) assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет def test_agent_status_right_after_hub_start(live): """Спека mcp «Статус агента сразу после перезапуска хаба».""" assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status() live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) text = mcp_server.agent_status() assert "запускатель: v2" in text and "неизвестно" not in text def _detail(**over): d = {"key": "A-1", "summary": "Т", "labels": [], "links_out": [], "links_in": [], "note": None, "commands": [], "comments": [], "description": None} d.update(over) return d def test_fmt_issue_comments(): assert "комментариев нет" in mcp_server.fmt_issue(_detail()) comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300", "updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)] text = mcp_server.fmt_issue(_detail(comments=comments)) assert "комментарии (7):" in text assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300 assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3 assert "ещё 2" in text assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:") no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None, "updated": None, "body": ""}])) assert "комментарии (1):" in no_author and "ещё" not in no_author