862 lines
46 KiB
Python
862 lines
46 KiB
Python
import inspect
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import threading
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from sdlc_hub.core import api, files, settings
|
||
from sdlc_hub.core import mcp as core_mcp
|
||
from sdlc_hub.modules.jira import mcp as jmcp
|
||
from sdlc_hub.modules.jira import commands
|
||
from sdlc_hub.core.client import HubClient
|
||
|
||
|
||
@pytest.fixture
|
||
def live(tmp_path, monkeypatch, add_project):
|
||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||
h.claim_wait = 0
|
||
conn = h.conn()
|
||
add_project(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(core_mcp, "client", lambda: HubClient(base, tok["client"]))
|
||
monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
|
||
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
|
||
yield {"agent": HubClient(base, tok["agents"]["jira"]), "client": HubClient(base, tok["client"])}
|
||
srv.shutdown()
|
||
srv.server_close()
|
||
|
||
|
||
def test_offline_message(monkeypatch):
|
||
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
|
||
monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
|
||
assert jmcp.issues() == core_mcp.OFFLINE
|
||
assert jmcp.agent_status() == core_mcp.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 = jmcp.issues(sprint="Спринт 2")
|
||
assert "A-1" in out and "Тема раз" in out and "ivanov" in out
|
||
assert "Аналитика" in jmcp.notes_set("A-1", work_kind="Аналитика")
|
||
detail = jmcp.issue("A-1")
|
||
assert "work_kind: Аналитика" in detail and "Готово" in detail
|
||
created = jmcp.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 = jmcp.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(jmcp.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 jmcp.batches(limit=5)
|
||
assert "Повтор команды" in jmcp.command_retry(cmd["id"])
|
||
assert "cancelled" in jmcp.batch_cancel(batch_id + 1)
|
||
r = asyncio.run(jmcp.refresh(full=True))
|
||
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r)
|
||
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(jmcp.refresh(keys=["A-1"], wait=False)))
|
||
assert "онлайн" in jmcp.agent_status()
|
||
assert "120" in jmcp.settings_get("pacing")
|
||
assert "settings_version" in jmcp.settings_set("pacing", json.dumps({"refresh": 1}))
|
||
assert jmcp.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
|
||
|
||
|
||
def test_no_fact_status_params():
|
||
for tool in (jmcp.issues, jmcp.notes_set):
|
||
assert "fact_status" not in inspect.signature(tool).parameters
|
||
|
||
|
||
def test_formatters():
|
||
assert jmcp.fmt_issues([]) == "задач нет"
|
||
text = jmcp.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None,
|
||
"sprint": None, "fact_status": "В работе"}])
|
||
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
|
||
head, row = text.splitlines()
|
||
assert head == "ключ | тема | статус | срок | исполнитель | спринт" # статуса по факту нет (fact-status-removal)
|
||
assert len(row.split(" | ")) == 6 and "В работе" not in row
|
||
assert "очередь" in jmcp.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 = jmcp.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 jmcp.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(jmcp, "time", SimpleNamespace(monotonic=monotonic))
|
||
monkeypatch.setattr(jmcp, "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(core_mcp, "client", lambda: fake)
|
||
clock = _fake_time(monkeypatch)
|
||
out = asyncio.run(jmcp.batch_wait(7, timeout_minutes=10))
|
||
assert out == jmcp.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(core_mcp, "client", lambda: fake)
|
||
clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов
|
||
out = asyncio.run(jmcp.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(core_mcp, "client", lambda: fake)
|
||
_fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута
|
||
asyncio.run(jmcp.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(core_mcp, "client", lambda: made.append(1) or fake)
|
||
_fake_time(monkeypatch)
|
||
asyncio.run(jmcp.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(core_mcp, "client", lambda: FakeClient(["done"], runs))
|
||
assert jmcp.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(jmcp.batch_wait) and asyncio.iscoroutinefunction(jmcp.refresh)
|
||
tools = {t.name: t for t in asyncio.run(jmcp.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(core_mcp, "PROJECT", "AWPCA")
|
||
monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
|
||
assert asyncio.run(jmcp.batch_wait(1)) == core_mcp.OFFLINE
|
||
assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE
|
||
|
||
|
||
def test_batch_wait_does_not_block_event_loop(monkeypatch):
|
||
"""Пока идёт ожидание, другая задача event loop продолжает выполняться."""
|
||
fake = FakeClient(["running", "running", "done"])
|
||
monkeypatch.setattr(core_mcp, "client", lambda: fake)
|
||
monkeypatch.setattr(jmcp, "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(jmcp.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 = jmcp.fmt_batch(b)
|
||
assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text
|
||
|
||
|
||
def test_batch_create_mentions_labels():
|
||
doc = jmcp.batch_create.__doc__ or ""
|
||
assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc
|
||
assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc
|
||
assert "AWPCA" not in doc
|
||
|
||
|
||
def test_batch_create_description_lists_params_from_spec():
|
||
"""Клиент MCP не видит specs/commands: описание инструмента перечисляет параметры каждого типа из SPEC (#44)."""
|
||
tools = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())}
|
||
desc = tools["batch_create"].description
|
||
for ctype, spec in commands.SPEC.items():
|
||
line = next(ln for ln in desc.splitlines() if ln.startswith(f"- {ctype}:"))
|
||
required, _, optional = line.removeprefix(f"- {ctype}:").partition("; [")
|
||
for name in spec["required"]:
|
||
assert re.search(rf"\b{name}\b", required), (ctype, name)
|
||
for name in spec["optional"]:
|
||
assert re.search(rf"\b{name}\b", optional), (ctype, name)
|
||
for name, hint in spec.get("hints", {}).items():
|
||
assert f"{name} ({hint})" in line, (ctype, name)
|
||
assert "target (статус или список-путь)" in desc and "sprint (имя спринта или backlog)" in desc
|
||
assert "метку проекта сессии хаб дописывает в ensure_issue сам" in desc and "AWPCA" not in desc
|
||
|
||
|
||
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 = jmcp.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 = jmcp.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 = jmcp.fmt_agent({**base, "agent_reported": False})
|
||
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text
|
||
assert "без запускателя" not in text and "агент запущен без run.py" not in text
|
||
assert "без запускателя" in jmcp.fmt_agent({**base, "agent_reported": True})
|
||
assert "без запускателя" in jmcp.fmt_agent(base) # старый хаб: поля agent_reported нет
|
||
|
||
|
||
def test_agent_status_right_after_hub_start(live):
|
||
"""Спека mcp «Статус агента сразу после перезапуска хаба»."""
|
||
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in jmcp.agent_status()
|
||
live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
|
||
text = jmcp.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 jmcp.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 = jmcp.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 " #7 Автор 7 2026-10-17T12:00:00.000+0300: ддд" in text and " #3 Автор 3 " in text # номер — для comment (#80)
|
||
assert "ещё 2" in text
|
||
assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:")
|
||
no_author = jmcp.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None,
|
||
"updated": None, "body": ""}]))
|
||
assert "комментарии (1):" in no_author and "ещё" not in no_author
|
||
|
||
|
||
def test_fmt_comment():
|
||
"""Один комментарий целиком (#80): переносы строк сохраняются, «изменён» — только если правили."""
|
||
body = "первая строка\n" + "т" * 1000
|
||
text = jmcp.fmt_comment("A-1", {"comment_id": "12", "author": "golov", "created": "2026-10-20T10:00:00.000+0300",
|
||
"updated": "2026-10-21T09:00:00.000+0300", "body": body})
|
||
assert text == ("A-1 #12 golov, создан 2026-10-20T10:00:00.000+0300, изменён 2026-10-21T09:00:00.000+0300\n" + body)
|
||
same = jmcp.fmt_comment("A-1", {"comment_id": "12", "author": None, "created": "2026-10-20T10:00:00.000+0300",
|
||
"updated": "2026-10-20T10:00:00.000+0300", "body": ""})
|
||
assert same == "A-1 #12 —, создан 2026-10-20T10:00:00.000+0300\n—"
|
||
|
||
|
||
def test_comment_tool(live, monkeypatch):
|
||
long = "абв " * 200 + "\nконец"
|
||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "Готово", "labels": ["AWPCA"],
|
||
"comments": [{"id": "101", "author": "golov", "body": long,
|
||
"created": "2026-10-20T10:00:00.000+0300"},
|
||
{"id": "102", "author": "ivanov", "body": "коротко",
|
||
"created": "2026-10-21T10:00:00.000+0300"}]}],
|
||
"links": []})
|
||
assert "#101 golov" in jmcp.issue("A-1") and long not in jmcp.issue("A-1")
|
||
out = jmcp.comment("A-1", "101")
|
||
assert out.startswith("A-1 #101 golov, создан 2026-10-20T10:00:00.000+0300\n") and out.endswith(long)
|
||
assert jmcp.comment("A-1", "#102").endswith("\nкоротко") # номер можно передать как в issue
|
||
assert jmcp.comment("A-1", "999") == "у A-1 нет комментария #999"
|
||
assert jmcp.comment("A-404", "1").startswith("ошибка хаба 404")
|
||
monkeypatch.setattr(core_mcp, "PROJECT", None)
|
||
assert jmcp.comment("A-1", "101") == core_mcp.NOT_BOUND
|
||
|
||
|
||
BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана",
|
||
"labels": ["AWPCA"]}}
|
||
|
||
|
||
def _batch_commands(out):
|
||
batch_id = int(out.split("#")[1].split()[0])
|
||
return core_mcp.client().get(f"/batches/{batch_id}")["commands"]
|
||
|
||
|
||
def _write(path, data):
|
||
path.write_bytes(data)
|
||
return str(path)
|
||
|
||
|
||
def test_batch_create_bug_with_local_file(live, tmp_path):
|
||
"""Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename."""
|
||
jmcp.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"},
|
||
"bug": {"name": "Ошибка", "id": None}}))
|
||
f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.xlsx"
|
||
f.write_bytes(b"PK\x03\x04 xlsx")
|
||
out = jmcp.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment",
|
||
"params": {"key": "new:1", "path": str(f)}}])
|
||
assert "3 команд" in out
|
||
cmds = _batch_commands(out)
|
||
sha = hashlib.sha256(f.read_bytes()).hexdigest()
|
||
assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_attachment", "refresh"]
|
||
assert cmds[1]["params"] == {"key": "new:1", "file": sha, "filename": "ОСВ сч. 60 за 2 кв. 2026.xlsx"}
|
||
assert (tmp_path / "files" / sha).read_bytes() == f.read_bytes()
|
||
|
||
|
||
def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch):
|
||
monkeypatch.setenv("HOME", str(tmp_path))
|
||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
|
||
(tmp_path / "скрин.png").write_bytes(b"png")
|
||
out = jmcp.batch_create("скрин", [{"type": "ensure_attachment", "params": {
|
||
"key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}])
|
||
assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(),
|
||
"filename": "Экран 1.png"}
|
||
|
||
|
||
@pytest.mark.parametrize("make, expected", [
|
||
(lambda d: str(d / "нет.xlsx"), "файл не найден: {p}"),
|
||
(lambda d: str(d), "не файл: {p}"),
|
||
(lambda d: "отчёт.xlsx", "нужен абсолютный путь: {p}"),
|
||
(lambda d: _write(d / "пусто.xlsx", b""), "пустой файл: {p}"),
|
||
(lambda d: _write(d / "большой.bin", b"\0" * (files.MAX_SIZE + 1)),
|
||
"файл больше 10 МБ (" + str(files.MAX_SIZE + 1) + " байт): {p}"),
|
||
])
|
||
def test_batch_create_bad_path(live, tmp_path, make, expected):
|
||
"""Спека mcp «Файла нет» и соседние причины: текст с путём, пакет не создан, в хаб ничего не загружено."""
|
||
src = tmp_path / "src"
|
||
src.mkdir()
|
||
p = make(src)
|
||
out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}])
|
||
assert out == expected.format(p=p)
|
||
assert core_mcp.client().get("/batches")["batches"] == []
|
||
assert list((tmp_path / "files").iterdir()) == []
|
||
|
||
|
||
def test_batch_create_unreadable_file(live, tmp_path):
|
||
f = tmp_path / "закрыт.xlsx"
|
||
f.write_bytes(b"x")
|
||
f.chmod(0)
|
||
try:
|
||
if os.access(f, os.R_OK):
|
||
pytest.skip("файл читается и без прав (root)")
|
||
out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}])
|
||
assert out == f"нет прав на чтение: {f}"
|
||
finally:
|
||
f.chmod(0o600)
|
||
|
||
|
||
def test_batch_create_path_and_file(live, tmp_path):
|
||
f = tmp_path / "a.xlsx"
|
||
f.write_bytes(b"a")
|
||
out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {
|
||
"key": "A-1", "path": str(f), "file": "0" * 64}}])
|
||
assert out == "у ensure_attachment либо path, либо file"
|
||
assert list((tmp_path / "files").iterdir()) == []
|
||
|
||
|
||
def test_batch_create_second_file_missing_uploads_nothing(live, tmp_path):
|
||
good = tmp_path / "a.xlsx"
|
||
good.write_bytes(b"a")
|
||
missing = tmp_path / "нет.xlsx"
|
||
out = jmcp.batch_create("x", [
|
||
{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(good)}},
|
||
{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(missing)}}])
|
||
assert out == f"файл не найден: {missing}"
|
||
assert list((tmp_path / "files").iterdir()) == []
|
||
|
||
|
||
def test_batch_create_hub_422_after_upload(live, tmp_path):
|
||
"""Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»)."""
|
||
f = tmp_path / "a.xlsx"
|
||
f.write_bytes(b"a")
|
||
out = jmcp.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}])
|
||
assert out == "ошибка хаба 422 bad_batch: команда 1: нет настройки issue_types.bug"
|
||
assert core_mcp.client().get("/batches")["batches"] == []
|
||
|
||
|
||
def test_batch_create_description_mentions_path():
|
||
desc = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())}["batch_create"].description
|
||
assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc
|
||
assert "issue_type (task|epic|bug)" in desc
|
||
assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc
|
||
|
||
|
||
def _projects():
|
||
"""Инструмент projects сервера jira: его строит register_core_tools, в модуле функции нет."""
|
||
return jmcp.mcp._tool_manager.get_tool("projects").fn()
|
||
|
||
|
||
def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта»
|
||
monkeypatch.setattr(core_mcp, "PROJECT", None)
|
||
calls = []
|
||
real = HubClient.call
|
||
monkeypatch.setattr(HubClient, "call", lambda self, m, p, *a, **k: calls.append(p) or real(self, m, p, *a, **k))
|
||
for out in (jmcp.issues(), jmcp.issue("A-1"), jmcp.notes_set("A-1", work_kind="x"),
|
||
jmcp.batch_create("x", []), jmcp.command_retry(1), asyncio.run(jmcp.refresh(full=True))):
|
||
assert out == core_mcp.NOT_BOUND
|
||
assert calls == []
|
||
for out in (jmcp.batches(), jmcp.sync_log(), jmcp.settings_get(), asyncio.run(jmcp.batch_wait(1))):
|
||
assert out != core_mcp.NOT_BOUND # не зависящие от проекта — работают без привязки
|
||
assert "AWPCA" in _projects()
|
||
assert jmcp.agent_status().startswith("агент ") # служебные инструменты работают без привязки
|
||
|
||
|
||
def test_unknown_bound_project(live, monkeypatch): # сценарий «Опечатка в проекте»
|
||
monkeypatch.setattr(core_mcp, "PROJECT", "STOKS")
|
||
assert jmcp.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json"
|
||
assert jmcp.agent_status().startswith("проект STOKS не заведён")
|
||
assert asyncio.run(jmcp.batch_wait(1)).startswith("проект STOKS не заведён")
|
||
assert "AWPCA" in _projects()
|
||
|
||
|
||
def test_bound_offline(monkeypatch): # Review Focus 4: хаб выключен — не «проект не заведён»
|
||
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
|
||
monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
|
||
assert jmcp.issues() == core_mcp.OFFLINE
|
||
assert jmcp.agent_status() == core_mcp.OFFLINE
|
||
assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE
|
||
assert _projects() == core_mcp.OFFLINE
|
||
|
||
|
||
def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки»
|
||
live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
|
||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "своя АРМ", "labels": ["AWPCA"]},
|
||
{"key": "S-1", "summary": "своя запасы", "labels": ["STOCKS_DRRK"]}], "links": []})
|
||
assert "A-1" in jmcp.issues() and "S-1" not in jmcp.issues()
|
||
out = jmcp.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}])
|
||
b = live["client"].get(f"/batches/{int(out.split('#')[1].split()[0])}")
|
||
assert b["project"] == "AWPCA" and b["commands"][0]["params"]["labels"] == ["AWPCA"]
|
||
assert jmcp.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA"
|
||
assert "work_kind: x" in jmcp.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues
|
||
assert "S-1" in jmcp.issue("S-1") # явный ключ — любая задача
|
||
assert jmcp.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422")
|
||
|
||
|
||
def test_bound_batches_and_retry(live, monkeypatch):
|
||
live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
|
||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
|
||
mine = jmcp.batch_create("моя", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}])
|
||
mine_id = int(mine.split("#")[1].split()[0])
|
||
monkeypatch.setattr(core_mcp, "PROJECT", "STOCKS_DRRK")
|
||
other = jmcp.batch_create("чужого проекта", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "с"}}])
|
||
other_id = int(other.split("#")[1].split()[0])
|
||
out = jmcp.batches(limit=50)
|
||
assert f"пакет #{other_id} " in out and f"пакет #{mine_id} " not in out
|
||
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
|
||
assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " not in jmcp.batches(limit=50)
|
||
assert f"#{other_id}" in jmcp.batch_status(other_id) # по id — без ограничения
|
||
other_cmd = live["client"].get(f"/batches/{other_id}")["commands"][0]["id"]
|
||
out = jmcp.command_retry(other_cmd)
|
||
assert out.startswith("ошибка хаба 422") and "не из проекта AWPCA" in out
|
||
monkeypatch.setattr(core_mcp, "PROJECT", None) # без привязки batches — все
|
||
assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " in jmcp.batches(limit=50)
|
||
|
||
|
||
def test_project_create(live, monkeypatch): # сценарий «Проект из чата»
|
||
monkeypatch.setattr(core_mcp, "PROJECT", None)
|
||
out = jmcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120)
|
||
assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out
|
||
frag = json.loads(out[out.index("{"):])
|
||
assert frag == core_mcp.mcp_json("STOCKS_DRRK", "jira")
|
||
assert frag["mcpServers"]["sdlc-jira"]["args"][-2:] == ["--project", "STOCKS_DRRK"]
|
||
assert live["client"].get("/batches")["batches"][0]["author"] == "claude"
|
||
assert jmcp.project_create("awpca", "x").startswith("ошибка хаба 409")
|
||
assert jmcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422")
|
||
|
||
|
||
def test_server_name():
|
||
assert jmcp.mcp.name == "sdlc-jira"
|
||
|
||
|
||
def test_hub_root_is_repo():
|
||
assert (core_mcp.HUB_ROOT / "pyproject.toml").is_file()
|
||
assert core_mcp.mcp_json("X", "jira")["mcpServers"]["sdlc-jira"]["args"][:3] == ["run", "--project", str(core_mcp.HUB_ROOT)]
|
||
|
||
|
||
def test_fmt_projects():
|
||
items = [{"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": "scrum", "issues_count": 190},
|
||
{"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": None, "board_type": None, "issues_count": 0}]
|
||
out = core_mcp.fmt_projects(items, "AWPCA", jmcp.PROJECT_COLUMNS).splitlines()
|
||
assert out[0] == "метка | название | доска | тип доски | задач"
|
||
assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)"
|
||
assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0"
|
||
assert core_mcp.fmt_projects([], None, jmcp.PROJECT_COLUMNS) == "проектов нет"
|
||
assert "(текущий)" not in core_mcp.fmt_projects(items, None, jmcp.PROJECT_COLUMNS)
|
||
|
||
|
||
def test_run_binds_project(monkeypatch):
|
||
monkeypatch.setattr(core_mcp, "PROJECT", None)
|
||
monkeypatch.setattr(jmcp.mcp, "run", lambda transport: None)
|
||
assert core_mcp.run("jira", "AWPCA") == 0
|
||
assert core_mcp.PROJECT == "AWPCA"
|
||
|
||
|
||
# --- лента изменений (#70, Task 5) ------------------------------------------------------------------------------
|
||
|
||
from datetime import datetime, timedelta, timezone # noqa: E402
|
||
|
||
|
||
def _local(at: str) -> datetime:
|
||
return datetime.fromisoformat(at).astimezone()
|
||
|
||
|
||
def _ev(**kw):
|
||
base = {"at": "2026-10-22T08:15:00+00:00", "key": "X", "summary": "Тема", "status": "Готово", "kind": "status",
|
||
"field": "status", "author": "Петров Пётр", "from": "В работе", "to": "Готово", "from_value": None,
|
||
"to_value": None}
|
||
return {**base, **kw}
|
||
|
||
|
||
def test_fmt_event_status():
|
||
e = _ev()
|
||
hm = _local(e["at"]).strftime("%H:%M")
|
||
assert jmcp.fmt_event(e, _local(e["at"]).date()) == f"{hm} Петров Пётр: статус «В работе» → «Готово»"
|
||
|
||
|
||
def test_fmt_event_other_day_has_date():
|
||
e = _ev()
|
||
d = _local(e["at"])
|
||
assert jmcp.fmt_event(e, d.date() + timedelta(days=1)).startswith(d.strftime("%d.%m %H:%M "))
|
||
assert jmcp.fmt_event(e, None).startswith(d.strftime("%d.%m %H:%M "))
|
||
|
||
|
||
def test_fmt_event_dates_by_value():
|
||
e = _ev(kind="dates", field="duedate", **{"from": "25/окт/26", "to": None}, from_value="2026-10-25", to_value=None)
|
||
assert jmcp.fmt_event(e, None).endswith(": срок 25.10 → —")
|
||
e = _ev(kind="dates", field="planned_end", **{"from": "сырое", "to": "30/окт/26"}, from_value=None, to_value="2026-10-30 00:00:00.0")
|
||
assert jmcp.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10")
|
||
|
||
|
||
def test_fmt_event_kinds():
|
||
assert jmcp.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено")
|
||
long = "к" * 250
|
||
out = jmcp.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None)
|
||
assert out.endswith(": комментарий: " + "к" * 199 + "…")
|
||
out = jmcp.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None)
|
||
assert out.endswith(": assignee: «—» → «ivanov»")
|
||
|
||
|
||
def test_fmt_changes_grouped():
|
||
now = datetime(2026, 10, 22, 14, 5, tzinfo=timezone.utc).astimezone()
|
||
data = {"since": "2026-10-21T21:00:00+00:00", "until": "2026-10-22T11:05:00+00:00", "truncated": True, "changes": [
|
||
_ev(at="2026-10-22T09:00:00+00:00", key="Y", summary="Вторая", status="В работе"),
|
||
_ev(at="2026-10-22T08:15:00+00:00"),
|
||
_ev(at="2026-10-22T08:00:00+00:00", key="Y", summary="Вторая", status="В работе", kind="description",
|
||
field="description")]}
|
||
lines = jmcp.fmt_changes(data, now).split("\n")
|
||
assert lines[0].startswith("изменения с ")
|
||
assert lines[1] == "Y Вторая — В работе" and lines[2].startswith(" ") and lines[3].endswith("описание изменено")
|
||
assert lines[4] == "X Тема — Готово"
|
||
assert lines[-1] == "показаны последние 3 событий, сузьте период"
|
||
|
||
|
||
def test_fmt_changes_empty():
|
||
out = jmcp.fmt_changes({"since": "2026-10-22T10:00:00+00:00", "until": "2026-10-22T11:00:00+00:00",
|
||
"truncated": False, "changes": []}, datetime.now().astimezone())
|
||
assert out.split("\n")[1] == "за период изменений нет"
|
||
|
||
|
||
def _history(live, key, n, created="2026-10-22T08:00:00Z", **kw):
|
||
items = [{"history_id": f"{key}{i}", "seq": 0, "key": key, "created": created, "author": "Петров",
|
||
"field_id": "status", "field_name": "status", "from": None, "from_string": "a", "to": None,
|
||
"to_string": f"s{i}", **kw} for i in range(n)]
|
||
live["agent"].post("/agent/history", {"changes": items})
|
||
|
||
|
||
def test_changes_today(live): # «Что закрылось сегодня» / «За два часа»
|
||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Готово",
|
||
"labels": ["AWPCA"]}], "links": []})
|
||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||
_history(live, "A-1", 1, created=(now - timedelta(minutes=30)).isoformat())
|
||
_history(live, "B-1", 1, created=(now - timedelta(minutes=30)).isoformat()) # не в проекте
|
||
out = jmcp.changes(kinds="status")
|
||
assert "A-1 Тема — Готово" in out and "статус «a» → «s0»" in out and "B-1" not in out
|
||
assert "A-1" in jmcp.changes(hours=2)
|
||
assert "B-1" in jmcp.changes(key="B-1")
|
||
|
||
|
||
def test_changes_hours_and_since(live):
|
||
assert jmcp.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since"
|
||
assert jmcp.changes(hours=0) == "hours — число больше 0"
|
||
|
||
|
||
def test_changes_bad_since(live):
|
||
assert jmcp.changes(since="вчера").startswith("ошибка хаба 422")
|
||
|
||
|
||
def test_changes_naive_since_is_local(live): # M-2: время без смещения — местное
|
||
out = jmcp.changes(since="2026-10-22T09:00", until="2026-10-22T10:00")
|
||
assert out.split("\n")[0] == "изменения с 22.10 09:00 по 22.10 10:00"
|
||
|
||
|
||
def test_changes_empty(live):
|
||
assert jmcp.changes(hours=1).split("\n")[1] == "за период изменений нет"
|
||
|
||
|
||
def test_changes_unbound(live, monkeypatch):
|
||
monkeypatch.setattr(core_mcp, "PROJECT", None)
|
||
assert jmcp.changes() == core_mcp.NOT_BOUND
|
||
assert jmcp.changes(key="A-1").split("\n")[1] == "за период изменений нет"
|
||
|
||
|
||
def test_issue_shows_changes(live): # «История в карточке»
|
||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "labels": ["AWPCA"]}], "links": []})
|
||
assert "изменения (" not in jmcp.issue("A-1")
|
||
_history(live, "A-1", 7)
|
||
out = jmcp.issue("A-1")
|
||
assert "изменения (7):" in out and " ещё 2" in out and out.count("статус «a»") == 5
|
||
|
||
|
||
def test_sync_log_history(live):
|
||
asyncio.run(jmcp.refresh(full=True))
|
||
cmd = live["agent"].post("/agent/claim", {})["command"]
|
||
assert "журнал: нет" in jmcp.sync_log()
|
||
live["agent"].post(f"/agent/commands/{cmd['id']}/result",
|
||
{"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}})
|
||
assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", jmcp.sync_log())
|
||
|
||
|
||
# --- вложения (#83) -----------------------------------------------------------------
|
||
|
||
ATT_KEY = "AURORA-1650"
|
||
|
||
|
||
def _att(aid, name, size, state="none", **over):
|
||
d = {"attachment_id": aid, "filename": name, "size": size, "mime_type": "image/png", "author": "golov",
|
||
"created": "2026-10-20T10:00:00.000+0300", "state": state, "error": None}
|
||
d.update(over)
|
||
return d
|
||
|
||
|
||
class FakeHub:
|
||
"""Фейковый HubClient: журнал вызовов и сценарий ответов по маршрутам."""
|
||
|
||
def __init__(self, issue, fetch=None, polls=None, data=b"x" * 10, online=True):
|
||
self.issue, self.fetch, self.polls, self.data, self.online = issue, fetch, polls or [], data, online
|
||
self.calls = []
|
||
|
||
def get(self, path, **params):
|
||
self.calls.append(("GET", path))
|
||
if path == f"/issues/{ATT_KEY}":
|
||
return self.issue
|
||
if path == "/agent":
|
||
return {"online": self.online}
|
||
if path.startswith("/attachments/"):
|
||
return self.polls.pop(0) if len(self.polls) > 1 else self.polls[0]
|
||
raise AssertionError(path)
|
||
|
||
def post(self, path, body=None):
|
||
self.calls.append(("POST", path, body))
|
||
if isinstance(self.fetch, Exception):
|
||
raise self.fetch
|
||
return self.fetch
|
||
|
||
def get_bytes(self, path):
|
||
self.calls.append(("BYTES", path))
|
||
return self.data
|
||
|
||
|
||
@pytest.fixture
|
||
def downloads(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
|
||
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
|
||
monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
|
||
return tmp_path / "home" / "downloads"
|
||
|
||
|
||
def _use(monkeypatch, hub):
|
||
monkeypatch.setattr(core_mcp, "client", lambda: hub)
|
||
monkeypatch.setattr(core_mcp, "unbound_or_unknown", lambda required: None)
|
||
|
||
|
||
def _issue_with_att():
|
||
return _detail(key=ATT_KEY, description="см. !скрин 1.png!", description_attachment_ids=["31337"],
|
||
comments=[{"comment_id": "101", "author": "golov", "created": "2026-10-20T10:00:00.000+0300",
|
||
"updated": None, "body": "!скрин 1.png!", "attachment_ids": ["31337"]}],
|
||
attachments=[_att("31338", "запись.mp4", 26214400, mime_type="video/mp4"),
|
||
_att("31337", "скрин 1.png", 239821, state="ready")])
|
||
|
||
|
||
def test_issue_attachments_block():
|
||
text = jmcp.fmt_issue(_issue_with_att())
|
||
assert "вложения (2):" in text and text.index("комментарии") < text.index("вложения (2):")
|
||
lines = {l.split()[0]: l for l in text.splitlines() if l.strip().startswith("#3133")}
|
||
assert "больше 10 МБ — только в Jira" in lines["#31338"] and "25,0 МБ" in lines["#31338"]
|
||
assert "(234,2 КБ)" in lines["#31337"] and "в описании, в #101, скачано" in lines["#31337"]
|
||
assert "файлы: #31337 скрин 1.png" in text
|
||
assert "вложений нет" in jmcp.fmt_issue(_detail(attachments=[]))
|
||
assert "вложений нет" in jmcp.fmt_issue(_detail())
|
||
|
||
|
||
def test_comment_attachments():
|
||
d = _issue_with_att()
|
||
text = jmcp.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"])
|
||
assert text.endswith("вложения:\n #31337 скрин 1.png (234,2 КБ)")
|
||
assert "вложения" not in jmcp.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"])
|
||
|
||
|
||
def test_safe_name_limits_255_bytes():
|
||
"""M-7: имя ≤ 255 байт UTF-8, расширение сохраняется, многобайтовый символ не режется."""
|
||
for stem in ("я" * 200, "ы" * 127 + "a"):
|
||
name = jmcp.safe_name(stem + ".xlsx", "7")
|
||
assert len(name.encode()) <= 255 and name.endswith(".xlsx")
|
||
name.encode("utf-8")
|
||
assert name.startswith("я") or name.startswith("ы")
|
||
assert len(jmcp.safe_name("ф" * 300, "7").encode()) <= 255
|
||
assert jmcp.safe_name("короткое.png", "7") == "короткое.png"
|
||
|
||
|
||
def test_attachment_long_cyrillic_name_written(downloads, monkeypatch):
|
||
name = "я" * 200 + ".png"
|
||
att = _att("31337", name, 3, state="ready")
|
||
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc")
|
||
_use(monkeypatch, hub)
|
||
out = asyncio.run(jmcp.attachment(ATT_KEY, "31337"))
|
||
path = Path(out.splitlines()[0])
|
||
assert path.is_file() and path.read_bytes() == b"abc" and len(path.name.encode()) <= 255
|
||
|
||
|
||
def test_safe_name():
|
||
assert jmcp.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png"
|
||
assert jmcp.safe_name(" ", "7") == "attachment-7"
|
||
assert jmcp.safe_name("..", "7") == "attachment-7"
|
||
assert jmcp.safe_name("a\x7f", "7") == "a_"
|
||
|
||
|
||
def _ready_hub(size=239821, data=b"PNG" * 3):
|
||
att = _att("31337", "скрин 1.png", size, state="ready")
|
||
return FakeHub(_detail(key=ATT_KEY, attachments=[att]),
|
||
fetch={"state": "ready", "command_id": 5}, polls=[att], data=data)
|
||
|
||
|
||
def test_attachment_by_id_ready(downloads, monkeypatch):
|
||
hub = _ready_hub()
|
||
_use(monkeypatch, hub)
|
||
out = asyncio.run(jmcp.attachment(ATT_KEY, "#31337"))
|
||
path = downloads / "31337" / "скрин 1.png"
|
||
lines = out.splitlines()
|
||
assert lines[0] == str(path) and lines[1] == "скрин 1.png (234,2 КБ), image/png, задача AURORA-1650"
|
||
assert path.read_bytes() == b"PNG" * 3
|
||
assert (downloads.stat().st_mode & 0o777) == 0o700 and ((downloads / "31337").stat().st_mode & 0o777) == 0o700
|
||
assert ("POST", "/attachments/31337/fetch", {"author": "claude"}) in hub.calls
|
||
|
||
|
||
def test_attachment_by_name_ambiguous(downloads, monkeypatch):
|
||
a20, a10 = _att("20", "скрин.png", 3), _att("10", "скрин.png", 3) # новые сверху, как отдаёт хаб
|
||
hub = FakeHub(_detail(key=ATT_KEY, attachments=[a20, a10]), fetch={"state": "ready", "command_id": 1},
|
||
polls=[{**a20, "state": "ready"}], data=b"abc")
|
||
_use(monkeypatch, hub)
|
||
out = asyncio.run(jmcp.attachment(ATT_KEY, "скрин.png"))
|
||
assert "у AURORA-1650 2 вложений «скрин.png», взято самое новое #20" in out
|
||
assert str(downloads / "20" / "скрин.png") in out
|
||
|
||
|
||
def test_attachment_not_found(downloads, monkeypatch):
|
||
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("1", "a.png", 1)]))
|
||
_use(monkeypatch, hub)
|
||
out = asyncio.run(jmcp.attachment(ATT_KEY, "нет.png"))
|
||
assert out == "у AURORA-1650 нет вложения «нет.png»; список — issue AURORA-1650"
|
||
assert all(c[1] == f"/issues/{ATT_KEY}" for c in hub.calls)
|
||
|
||
|
||
def test_attachment_wait_timeout(downloads, monkeypatch):
|
||
att = _att("1", "a.png", 5)
|
||
fetching = {**att, "state": "fetching", "command_id": 9}
|
||
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=fetching, polls=[fetching])
|
||
_use(monkeypatch, hub)
|
||
out = asyncio.run(jmcp.attachment(ATT_KEY, "1", wait_seconds=1))
|
||
assert out == "скачивание ещё идёт (команда #9, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится"
|
||
|
||
|
||
def test_attachment_failed(downloads, monkeypatch):
|
||
att = _att("1", "a.png", 5)
|
||
failed = {**att, "state": "failed", "error": "HTTP 404", "command_id": 3}
|
||
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "fetching", "command_id": 3}, polls=[failed])
|
||
_use(monkeypatch, hub)
|
||
assert asyncio.run(jmcp.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404"
|
||
|
||
|
||
def test_attachment_413(downloads, monkeypatch):
|
||
from sdlc_hub.core.client import HubError
|
||
att = _att("5", "запись.mp4", 26214400)
|
||
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой"))
|
||
_use(monkeypatch, hub)
|
||
assert asyncio.run(jmcp.attachment(ATT_KEY, "#5")) == \
|
||
"файл больше 10 МБ (25,0 МБ) — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»"
|
||
|
||
|
||
def test_attachment_409_passes_hub_text(downloads, monkeypatch):
|
||
from sdlc_hub.core.client import HubError
|
||
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]),
|
||
fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14"))
|
||
_use(monkeypatch, hub)
|
||
assert "агент v9 не умеет" in asyncio.run(jmcp.attachment(ATT_KEY, "5"))
|
||
|
||
|
||
def test_attachment_existing_copy_same_size(downloads, monkeypatch):
|
||
hub = _ready_hub(data=b"PNG" * 3)
|
||
_use(monkeypatch, hub)
|
||
path = downloads / "31337" / "скрин 1.png"
|
||
path.parent.mkdir(parents=True)
|
||
path.write_bytes(b"OLD" * 3)
|
||
os.utime(path, (1_000_000, 1_000_000))
|
||
os.utime(path.parent, (1_000_000, 1_000_000))
|
||
asyncio.run(jmcp.attachment(ATT_KEY, "31337"))
|
||
assert path.read_bytes() == b"OLD" * 3
|
||
assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет
|