Агент v11: тип «Ошибка», ensure_attachment, устойчивое дочитывание комментариев (#31) #32
@ -8,7 +8,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|
||||
| Раздел | Классы и функции | Ответственность | Что использует |
|
||||
|---|---|---|---|
|
||||
| Настройки | `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` |
|
||||
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
|
||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
|
||||
|
||||
49
src/agent.py
49
src/agent.py
@ -31,7 +31,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
AGENT_VERSION = 10
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
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):
|
||||
def __init__(self, code: int, text: str, headers=None):
|
||||
super().__init__("HTTP %d: %s" % (code, text[:300]))
|
||||
@ -112,16 +126,22 @@ class Http:
|
||||
return "<%s %s>" % (self.__class__.__name__, self.base)
|
||||
|
||||
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
|
||||
if params:
|
||||
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)
|
||||
for k, v in self.headers.items():
|
||||
req.add_header(k, v)
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json; charset=utf-8")
|
||||
if content_type is not None:
|
||||
req.add_header("Content-Type", content_type)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp:
|
||||
raw = resp.read()
|
||||
@ -184,10 +204,11 @@ class JiraClient(Http):
|
||||
def reset_counters(self) -> None:
|
||||
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):
|
||||
try:
|
||||
return super().call(method, path, body, params)
|
||||
return request()
|
||||
except HttpError as e:
|
||||
if e.code in (429, 503) and attempt < 5:
|
||||
wait = 60 * attempt
|
||||
@ -203,6 +224,18 @@ class JiraClient(Http):
|
||||
self.rate_limited += 1
|
||||
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):
|
||||
return self.call("GET", path, params=params or None)
|
||||
|
||||
@ -330,6 +363,10 @@ class HubClient(Http):
|
||||
def known_keys(self) -> List[str]:
|
||||
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 команды
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
@ -23,6 +25,8 @@ class FakeHub:
|
||||
self.in_flight = None
|
||||
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
|
||||
self.release_requests = 0
|
||||
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
|
||||
self.file_requests = 0
|
||||
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
|
||||
self.lock = threading.Lock()
|
||||
hub = self
|
||||
@ -54,6 +58,19 @@ class FakeHub:
|
||||
self.queue.append(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):
|
||||
r = self.release
|
||||
return {"version": r["version"], "sha256": r["sha256"]} if r else None
|
||||
@ -97,7 +114,7 @@ class FakeHub:
|
||||
self.commands[cid]["status"] = "claimed"
|
||||
self.in_flight = 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()}
|
||||
if path == "/agent/release":
|
||||
self.release_requests += 1
|
||||
@ -109,10 +126,17 @@ class FakeHub:
|
||||
if path == "/agent/state":
|
||||
self.states.append(body)
|
||||
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)
|
||||
if m:
|
||||
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)
|
||||
if m:
|
||||
cid = int(m.group(1))
|
||||
|
||||
@ -6,7 +6,7 @@ import socket
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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))
|
||||
|
||||
@ -24,6 +24,43 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал
|
||||
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:
|
||||
def __init__(self, project="AURORA"):
|
||||
self.project = project
|
||||
@ -39,6 +76,13 @@ class FakeJira:
|
||||
self.queries = [] # параметры запросов (q) — для проверки JQL
|
||||
self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа
|
||||
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.lock = threading.Lock()
|
||||
fake = self
|
||||
@ -94,6 +138,16 @@ class FakeJira:
|
||||
self.comments.setdefault(key, []).append(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):
|
||||
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]
|
||||
out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst),
|
||||
"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}
|
||||
|
||||
def links_of(self, key):
|
||||
@ -149,8 +205,14 @@ class FakeJira:
|
||||
url = urlparse(h.path)
|
||||
q = {k: v[0] for k, v in parse_qs(url.query).items()}
|
||||
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:
|
||||
self.last_headers = {k.lower(): v for k, v in h.headers.items()}
|
||||
self.requests.append((method, url.path))
|
||||
self.queries.append(q)
|
||||
if self.fail_next:
|
||||
@ -167,6 +229,11 @@ class FakeJira:
|
||||
h.close_connection = True
|
||||
h.connection.shutdown(socket.SHUT_RDWR) # задача создана, ответа клиент не получит
|
||||
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)
|
||||
|
||||
def _send(self, h, code, payload, headers=None):
|
||||
@ -253,6 +320,26 @@ class FakeJira:
|
||||
if c["id"] == m.group(2):
|
||||
c["body"] = body["body"]
|
||||
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)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
import urllib.parse
|
||||
|
||||
from _common import agent, jira_cfg, no_sleep
|
||||
from fake_jira import FakeJira
|
||||
@ -21,6 +22,74 @@ class ClientsTest(unittest.TestCase):
|
||||
self.assertIsNotNone(self.jira.last_call_at)
|
||||
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):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@ -137,6 +138,30 @@ class FolderTransportTest(unittest.TestCase):
|
||||
self.assertIsNone(hub.folder)
|
||||
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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user