feat(agent): multipart-загрузка вложения в Jira и файл вложения у хаба (#31)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 01:48:38 +04:00
parent 90dc429434
commit 2bf87a6fa2
6 changed files with 253 additions and 11 deletions

View File

@ -8,7 +8,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
| Раздел | Классы и функции | Ответственность | Что использует | | Раздел | Классы и функции | Ответственность | Что использует |
|---|---|---|---| |---|---|---|---|
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — | | Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `urllib` | | HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` |
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба | | Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |

View File

@ -31,7 +31,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
AGENT_VERSION = 10 AGENT_VERSION = 10
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
@ -98,6 +98,20 @@ def write_update_state(path: str, state: str, from_version, to_version, sha256,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# HTTP # HTTP
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def wire_filename(name: str) -> str:
"""Имя для filename="…" в multipart: кавычка и переводы строк — %22, %0D, %0A, как у браузеров (HTML5);
остальное — UTF-8 как есть (Jira Server читает filename в кодировке запроса)."""
return name.replace('"', "%22").replace("\r", "%0D").replace("\n", "%0A")
def multipart_file(boundary: str, filename: str, data: bytes) -> bytes:
"""Тело multipart/form-data с одной частью file (спека agent «Тип задачи и вложения»)."""
disposition = "form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s" % (
wire_filename(filename), urllib.parse.quote(filename, safe=""))
head = "--%s\r\nContent-Disposition: %s\r\nContent-Type: application/octet-stream\r\n\r\n" % (boundary, disposition)
return head.encode("utf-8") + data + ("\r\n--%s--\r\n" % boundary).encode("ascii")
class HttpError(Exception): class HttpError(Exception):
def __init__(self, code: int, text: str, headers=None): def __init__(self, code: int, text: str, headers=None):
super().__init__("HTTP %d: %s" % (code, text[:300])) super().__init__("HTTP %d: %s" % (code, text[:300]))
@ -112,16 +126,22 @@ class Http:
return "<%s %s>" % (self.__class__.__name__, self.base) return "<%s %s>" % (self.__class__.__name__, self.base)
def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any: def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any:
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
return self.send(method, path, data, "application/json; charset=utf-8" if data is not None else None,
params, timeout)
def send(self, method: str, path: str, data: Optional[bytes], content_type: Optional[str], params=None,
timeout=None) -> Any:
"""Запрос с готовым телом (JSON или multipart); ответ — JSON. Ошибки — HttpError и ConnectionError."""
url = self.base + path url = self.base + path
if params: if params:
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method) req = urllib.request.Request(url, data=data, method=method)
for k, v in self.headers.items(): for k, v in self.headers.items():
req.add_header(k, v) req.add_header(k, v)
req.add_header("Accept", "application/json") req.add_header("Accept", "application/json")
if data is not None: if content_type is not None:
req.add_header("Content-Type", "application/json; charset=utf-8") req.add_header("Content-Type", content_type)
try: try:
with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp: with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp:
raw = resp.read() raw = resp.read()
@ -184,10 +204,11 @@ class JiraClient(Http):
def reset_counters(self) -> None: def reset_counters(self) -> None:
self.calls, self.rate_limited = 0, 0 self.calls, self.rate_limited = 0, 0
def call(self, method, path, body=None, params=None): def _retry(self, request: Callable[[], Any]) -> Any:
"""429/503 — повтор по Retry-After (до 5 запросов, не более 4 ожиданий); прочие ошибки — наружу."""
for attempt in range(1, 6): for attempt in range(1, 6):
try: try:
return super().call(method, path, body, params) return request()
except HttpError as e: except HttpError as e:
if e.code in (429, 503) and attempt < 5: if e.code in (429, 503) and attempt < 5:
wait = 60 * attempt wait = 60 * attempt
@ -203,6 +224,18 @@ class JiraClient(Http):
self.rate_limited += 1 self.rate_limited += 1
raise raise
def call(self, method, path, body=None, params=None):
return self._retry(lambda: Http.call(self, method, path, body, params))
def upload(self, key: str, filename: str, data: bytes) -> Any:
"""Вложение в задачу: multipart, X-Atlassian-Token: no-check (заголовок клиента), таймаут не меньше 120 с.
Содержимое не логируется."""
boundary = uuid.uuid4().hex
body = multipart_file(boundary, filename, data)
return self._retry(lambda: self.send("POST", API + "/issue/%s/attachments" % key, body,
"multipart/form-data; boundary=" + boundary,
timeout=max(self.timeout, 120)))
def get(self, path, **params): def get(self, path, **params):
return self.call("GET", path, params=params or None) return self.call("GET", path, params=params or None)
@ -330,6 +363,10 @@ class HubClient(Http):
def known_keys(self) -> List[str]: def known_keys(self) -> List[str]:
return self.call("GET", "/agent/known-keys")["keys"] return self.call("GET", "/agent/known-keys")["keys"]
def file(self, sha256: str) -> dict:
"""Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file."""
return self.call("GET", "/agent/files/%s" % sha256)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Журнал: только дозапись; started → finished → acked по id команды # Журнал: только дозапись; started → finished → acked по id команды

View File

@ -1,4 +1,6 @@
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти.""" """Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
import base64
import hashlib
import json import json
import re import re
import threading import threading
@ -23,6 +25,8 @@ class FakeHub:
self.in_flight = None self.in_flight = None
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
self.release_requests = 0 self.release_requests = 0
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
self.file_requests = 0
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
self.lock = threading.Lock() self.lock = threading.Lock()
hub = self hub = self
@ -54,6 +58,19 @@ class FakeHub:
self.queue.append(cid) self.queue.append(cid)
return cid return cid
def put_file(self, data):
sha = hashlib.sha256(data).hexdigest()
self.files[sha] = data
return sha
def _issued(self, c):
"""Параметры при выдаче: как хаб, к ensure_attachment дописывается size файла (None — файла нет)."""
params = dict(c["params"])
if c["type"] == "ensure_attachment":
data = self.files.get(params.get("file"))
params["size"] = len(data) if data is not None else None
return params
def _release_ref(self): def _release_ref(self):
r = self.release r = self.release
return {"version": r["version"], "sha256": r["sha256"]} if r else None return {"version": r["version"], "sha256": r["sha256"]} if r else None
@ -97,7 +114,7 @@ class FakeHub:
self.commands[cid]["status"] = "claimed" self.commands[cid]["status"] = "claimed"
self.in_flight = cid self.in_flight = cid
c = self.commands[cid] c = self.commands[cid]
return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]}, return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]},
"settings_version": self.settings["settings_version"], "agent_release": self._release_ref()} "settings_version": self.settings["settings_version"], "agent_release": self._release_ref()}
if path == "/agent/release": if path == "/agent/release":
self.release_requests += 1 self.release_requests += 1
@ -109,10 +126,17 @@ class FakeHub:
if path == "/agent/state": if path == "/agent/state":
self.states.append(body) self.states.append(body)
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])} return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
m = re.match(r"^/agent/files/([^/]+)$", path)
if m and method == "GET":
self.file_requests += 1
data = self.files.get(m.group(1))
if data is None:
return 404, {"error": "no_file", "detail": "файла %s нет в хранилище" % m.group(1)}
return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")}
m = re.match(r"^/agent/commands/(\d+)$", path) m = re.match(r"^/agent/commands/(\d+)$", path)
if m: if m:
c = self.commands.get(int(m.group(1))) c = self.commands.get(int(m.group(1)))
return (200, c) if c else (404, {"error": "not_found"}) return (200, dict(c, params=self._issued(c))) if c else (404, {"error": "not_found"})
m = re.match(r"^/agent/commands/(\d+)/result$", path) m = re.match(r"^/agent/commands/(\d+)/result$", path)
if m: if m:
cid = int(m.group(1)) cid = int(m.group(1))

View File

@ -6,7 +6,7 @@ import socket
import threading import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, unquote, urlparse
MSK = timezone(timedelta(hours=3)) MSK = timezone(timedelta(hours=3))
@ -24,6 +24,43 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал
CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304")
def parse_multipart(raw, content_type):
"""Строгий разбор multipart/form-data, как commons-fileupload Jira Server с кодировкой заголовков UTF-8:
[{"headers": {имя в нижнем регистре: значение}, "data": bytes}]. Нарушение формата — AssertionError (фейк ответит 400)."""
m = re.search(r"boundary=([^;\s]+)", content_type)
assert m, "нет boundary в Content-Type"
delim = b"--" + m.group(1).encode("ascii")
head, tail = delim + b"\r\n", b"\r\n" + delim + b"--\r\n"
assert raw.startswith(head), "тело не начинается с границы"
assert raw.endswith(tail), "нет закрывающей границы"
parts = []
for chunk in raw[len(head):len(raw) - len(tail)].split(b"\r\n" + delim + b"\r\n"):
hdr, sep, data = chunk.partition(b"\r\n\r\n")
assert sep, "у части нет пустой строки после заголовков"
headers = {}
for line in hdr.decode("utf-8").split("\r\n"):
name, _, value = line.partition(":")
headers[name.strip().lower()] = value.strip()
parts.append({"headers": headers, "data": data})
return parts
def disposition_params(value):
"""Параметры Content-Disposition части: name; filename — как в кавычках, без percent-декодирования (так читает
Jira Server); filename* — после UTF-8'' с percent-декодированием."""
out = {}
m = re.search(r'(?:^|;)\s*name="([^"]*)"', value)
if m:
out["name"] = m.group(1)
m = re.search(r'(?:^|;)\s*filename="([^"]*)"', value)
if m:
out["filename"] = m.group(1)
m = re.search(r"(?:^|;)\s*filename\*=UTF-8''([^;\s]+)", value)
if m:
out["filename*"] = unquote(m.group(1), encoding="utf-8", errors="strict")
return out
class FakeJira: class FakeJira:
def __init__(self, project="AURORA"): def __init__(self, project="AURORA"):
self.project = project self.project = project
@ -39,6 +76,13 @@ class FakeJira:
self.queries = [] # параметры запросов (q) — для проверки JQL self.queries = [] # параметры запросов (q) — для проверки JQL
self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа
self.comment_denied = {} # ключ → код ответа на GET /issue/{key}/comment (403 — нет прав, 404 — нет задачи) self.comment_denied = {} # ключ → код ответа на GET /issue/{key}/comment (403 — нет прав, 404 — нет задачи)
self.attachments = {} # ключ → [{id, filename, size, mimeType, data}]
self.attachment_seq = 20000
self.uploads = [] # разобранные POST /attachments: {key, token, content_type, headers, disposition}
self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа
self.attachment_limit = None # байт: больше — 413, как предел вложений Jira
self.bad_attachment_response = False # ответить на загрузку пустым списком
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
self.lock = threading.Lock() self.lock = threading.Lock()
fake = self fake = self
@ -94,6 +138,16 @@ class FakeJira:
self.comments.setdefault(key, []).append(c) self.comments.setdefault(key, []).append(c)
return c return c
def add_attachment(self, key, filename, data, mime="application/octet-stream"):
self.attachment_seq += 1
a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data}
self.attachments.setdefault(key, []).append(a)
return a
def attachment_json(self, a):
return {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"],
"filename": a["filename"], "size": a["size"], "mimeType": a["mimeType"]}
def writes(self): def writes(self):
return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")] return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")]
@ -115,6 +169,8 @@ class FakeJira:
shown = lst if cap is None else lst[:cap] shown = lst if cap is None else lst[:cap]
out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst), out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst),
"comments": shown} "comments": shown}
if fields is None or "attachment" in fields:
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
return {"key": key, "id": f["id"], "fields": out} return {"key": key, "id": f["id"], "fields": out}
def links_of(self, key): def links_of(self, key):
@ -149,8 +205,14 @@ class FakeJira:
url = urlparse(h.path) url = urlparse(h.path)
q = {k: v[0] for k, v in parse_qs(url.query).items()} q = {k: v[0] for k, v in parse_qs(url.query).items()}
length = int(h.headers.get("Content-Length") or 0) length = int(h.headers.get("Content-Length") or 0)
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} raw = h.rfile.read(length) if length else b""
ctype = h.headers.get("Content-Type") or ""
if ctype.startswith("multipart/form-data"):
body = {"_raw": raw, "_content_type": ctype} # разбирает маршрут: ошибка формата — 400
else:
body = json.loads(raw.decode("utf-8")) if raw else {}
with self.lock: with self.lock:
self.last_headers = {k.lower(): v for k, v in h.headers.items()}
self.requests.append((method, url.path)) self.requests.append((method, url.path))
self.queries.append(q) self.queries.append(q)
if self.fail_next: if self.fail_next:
@ -167,6 +229,11 @@ class FakeJira:
h.close_connection = True h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # задача создана, ответа клиент не получит h.connection.shutdown(socket.SHUT_RDWR) # задача создана, ответа клиент не получит
return return
if self.drop_next_post_attachment and method == "POST" and url.path.endswith("/attachments"):
self.drop_next_post_attachment = False
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # вложение сохранено, ответа клиент не получит
return
self._send(h, code, payload) self._send(h, code, payload)
def _send(self, h, code, payload, headers=None): def _send(self, h, code, payload, headers=None):
@ -253,6 +320,26 @@ class FakeJira:
if c["id"] == m.group(2): if c["id"] == m.group(2):
c["body"] = body["body"] c["body"] = body["body"]
return 200, {} return 200, {}
m = re.match(r"^/rest/api/2/issue/([^/]+)/attachments$", p)
if m and method == "POST":
key = m.group(1)
self.issues[key]
if self.last_headers.get("x-atlassian-token") != "no-check":
return 403, {"errorMessages": ["XSRF check failed"]} # как Jira Server без заголовка
parts = parse_multipart(body["_raw"], body["_content_type"])
assert len(parts) == 1, "ожидалась одна часть, пришло %d" % len(parts)
part = parts[0]
disp = disposition_params(part["headers"].get("content-disposition", ""))
assert disp.get("name") == "file", "часть называется не file: %r" % disp
self.uploads.append({"key": key, "token": self.last_headers.get("x-atlassian-token"),
"content_type": body["_content_type"], "headers": part["headers"], "disposition": disp})
if self.attachment_limit is not None and len(part["data"]) > self.attachment_limit:
return 413, {"errorMessages": ["Размер вложения превышает предел %d байт" % self.attachment_limit]}
a = self.add_attachment(key, disp["filename"], part["data"],
part["headers"].get("content-type") or "application/octet-stream")
if self.bad_attachment_response:
return 200, []
return 200, [self.attachment_json(a)]
m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p) m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p)
if m: if m:
key = m.group(1) key = m.group(1)

View File

@ -1,4 +1,5 @@
import unittest import unittest
import urllib.parse
from _common import agent, jira_cfg, no_sleep from _common import agent, jira_cfg, no_sleep
from fake_jira import FakeJira from fake_jira import FakeJira
@ -21,6 +22,74 @@ class ClientsTest(unittest.TestCase):
self.assertIsNotNone(self.jira.last_call_at) self.assertIsNotNone(self.jira.last_call_at)
self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1")) self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1"))
def test_multipart_file_bytes(self):
body = agent.multipart_file("b0", "Отчёт.txt", b"x\r\ny")
q = urllib.parse.quote("Отчёт.txt", safe="")
expected = ('--b0\r\nContent-Disposition: form-data; name="file"; filename="Отчёт.txt"; '
"filename*=UTF-8''%s\r\nContent-Type: application/octet-stream\r\n\r\n" % q).encode("utf-8") \
+ b"x\r\ny" + b"\r\n--b0--\r\n"
self.assertEqual(body, expected)
def test_wire_filename(self):
self.assertEqual(agent.wire_filename('а "б"\r\n.txt'), "а %22б%22%0D%0A.txt")
self.assertEqual(agent.wire_filename("ОСВ 60.xlsx"), "ОСВ 60.xlsx")
def test_upload_cyrillic_name(self):
self.fake.add("AURORA-1")
name = "ОСВ сч. 60 за 2 кв. 2026.xlsx"
data = bytes(range(256)) * 40
resp = self.jira.upload("AURORA-1", name, data)
self.assertIsInstance(resp, list)
a = self.fake.attachments["AURORA-1"][0]
self.assertEqual((resp[0]["id"], a["filename"], a["size"], a["data"]), (a["id"], name, len(data), data))
up = self.fake.uploads[-1]
self.assertEqual(up["token"], "no-check")
self.assertTrue(up["content_type"].startswith("multipart/form-data; boundary="))
self.assertEqual(len(up["content_type"].split("boundary=")[1]), 32) # uuid4().hex
self.assertEqual(up["headers"]["content-type"], "application/octet-stream")
self.assertEqual(up["disposition"]["filename*"], name)
self.assertEqual(self.jira.calls, 1)
self.assertEqual(self.fake.requests[-1], ("POST", "/rest/api/2/issue/AURORA-1/attachments"))
def test_upload_filename_with_quote_and_newline(self):
self.fake.add("AURORA-1")
name = 'итог "кв.2"\r\n.txt'
self.jira.upload("AURORA-1", name, b"abc")
up = self.fake.uploads[-1]
self.assertEqual(up["disposition"]["filename"], "итог %22кв.2%22%0D%0A.txt")
self.assertEqual(up["disposition"]["filename*"], name)
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
self.assertEqual(self.fake.attachments["AURORA-1"][0]["data"], b"abc")
def test_upload_needs_xsrf_header(self):
self.fake.add("AURORA-1")
self.jira.headers = {k: v for k, v in self.jira.headers.items() if k != "X-Atlassian-Token"}
with self.assertRaises(agent.HttpError) as cm:
self.jira.upload("AURORA-1", "a.txt", b"abc")
self.assertEqual(cm.exception.code, 403)
self.assertNotIn("AURORA-1", self.fake.attachments)
def test_upload_retries_429(self):
self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"Retry-After": "0"}))
self.jira.upload("AURORA-1", "a.txt", b"abc")
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
self.assertEqual(self.jira.rate_limited, 1)
self.assertEqual(self.jira.calls, 2)
def test_upload_timeout_at_least_120(self):
self.fake.add("AURORA-1")
seen = []
orig = self.jira.send
def spy(*a, **kw):
seen.append(kw.get("timeout"))
return orig(*a, **kw)
self.jira.send = spy
self.jira.upload("AURORA-1", "a.txt", b"abc")
self.assertEqual(seen, [120]) # timeout в конфиге тестов — 5 с
def test_retry_on_429_with_retry_after(self): def test_retry_on_429_with_retry_after(self):
self.fake.add("AURORA-1") self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"Retry-After": "0"})) self.fake.fail_next.append((429, {"Retry-After": "0"}))

View File

@ -1,3 +1,4 @@
import base64
import io import io
import json import json
import os import os
@ -137,6 +138,30 @@ class FolderTransportTest(unittest.TestCase):
self.assertIsNone(hub.folder) self.assertIsNone(hub.folder)
self.assertEqual(hub.settings()["project_key"], "AURORA") self.assertEqual(hub.settings()["project_key"], "AURORA")
def test_file_through_folder(self):
self.run_relay()
data = os.urandom(300 * 1024)
sha = self.hub.put_file(data)
got = self.client().file(sha)
self.assertEqual((got["file"], got["size"]), (sha, len(data)))
self.assertEqual(base64.b64decode(got["content"]), data)
self.assertEqual(self.relay.requests[0]["path"], "/agent/files/" + sha)
self.assertEqual(self.relay.requests[0]["method"], "GET")
def test_file_missing_through_folder(self):
self.run_relay()
with self.assertRaises(agent.HttpError) as e:
self.client().file("0" * 64)
self.assertEqual(e.exception.code, 404)
self.assertIn("no_file", e.exception.text)
def test_file_over_http(self):
sha = self.hub.put_file(b"abc")
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
got = hub.file(sha)
self.assertEqual(base64.b64decode(got["content"]), b"abc")
self.assertEqual(self.hub.file_requests, 1)
class FolderIntegrationTest(unittest.TestCase): class FolderIntegrationTest(unittest.TestCase):