API вложений: файл от агента, скачивание, уборка копий (#83)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d2e68a9ab1
commit
722393daf9
@ -16,8 +16,8 @@
|
||||
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
|
||||
| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты (для `fetch_attachment` в `done` — проверка файла в хранилище и запись `attachment_file`), `request_fetch` (постановка скачивания вложения в одной транзакции, `TooLarge`, `AgentTooOld`; пакет без хвостового `refresh` и без резервной копии), отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `attachments`, `db` |
|
||||
| `backup` | копии файла базы, ротация | `paths` |
|
||||
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` |
|
||||
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `relay`, `release` |
|
||||
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ, кроме `allow_empty` для файла от агента), `touch` (mtime при выдаче содержимого), `sweep_downloads` (каталоги копий MCP `downloads/<id>/`, старше 30 дней), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` |
|
||||
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}`, `POST /agent/files` (от агента, пустой допустим; каталог `Hub.files_dir`, 0700 при старте); вложения `GET /attachments/{id}`, `POST /attachments/{id}/fetch` (`queue.request_fetch`: `TooLarge` → 413, `AgentTooOld` → 409, нет → 404 `no_attachment`), `GET /attachments/{id}/content` (`Content-Disposition: filename*=UTF-8''…`, `nosniff`, `files.touch`); уборка раз в сутки трогает и `Hub.downloads_dir`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `attachments`, `relay`, `release` |
|
||||
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
|
||||
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
|
||||
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
|
||||
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
|
||||
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
|
||||
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.
|
||||
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `<id>/<имя>`, уборка 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.
|
||||
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
|
||||
|
||||
## Архитектура
|
||||
|
||||
@ -13,9 +13,9 @@ from datetime import timedelta
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
|
||||
from . import backup, db, files, history, mirror, projects, queue, relay, release, settings
|
||||
from . import attachments, backup, db, files, history, mirror, projects, queue, relay, release, settings
|
||||
from .commands import CommandError
|
||||
from .paths import backups_dir as default_backups_dir
|
||||
from .paths import secure_dir
|
||||
@ -31,6 +31,7 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им
|
||||
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
|
||||
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
|
||||
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
|
||||
MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+") # тип/подтип без параметров и пробелов: заголовок не внушить
|
||||
SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)
|
||||
|
||||
|
||||
@ -54,6 +55,7 @@ class Hub:
|
||||
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
|
||||
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
|
||||
self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files"
|
||||
self.downloads_dir = self.db_path.parent / "downloads" # копии вложений для MCP; каталог создаёт MCP
|
||||
self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15)
|
||||
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
|
||||
# неизвестен, а не null (hub-hygiene-d, решение 7)
|
||||
@ -96,6 +98,12 @@ class Hub:
|
||||
removed = files.sweep(conn, self.files_dir, now)
|
||||
if removed:
|
||||
log.info("уборка файлов: удалено %d", len(removed))
|
||||
try:
|
||||
gone = files.sweep_downloads(self.downloads_dir, now)
|
||||
if gone:
|
||||
log.info("уборка копий вложений: удалено %d", len(gone))
|
||||
except Exception: # noqa: BLE001 — копии MCP не должны мешать основной уборке
|
||||
log.exception("уборка копий вложений: сбой")
|
||||
return removed
|
||||
|
||||
def note_agent_update(self, body):
|
||||
@ -355,9 +363,9 @@ def h_batch_cancel(hub, conn, m, q, body):
|
||||
raise ApiError(404, "not_found", "batch")
|
||||
|
||||
|
||||
def h_files_post(hub, conn, m, q, body):
|
||||
"""Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64)
|
||||
отбрасываются до строгой проверки (решение 14)."""
|
||||
def _put_file(hub, body, allow_empty=False):
|
||||
"""Общая часть POST /files и POST /agent/files: {content: base64} → {file: sha256, size}. Пробельные символы
|
||||
(переносы строк base64) отбрасываются до строгой проверки (решение 14)."""
|
||||
content = body.get("content")
|
||||
if not isinstance(content, str):
|
||||
raise ApiError(400, "bad_request", "content — строка base64")
|
||||
@ -366,7 +374,7 @@ def h_files_post(hub, conn, m, q, body):
|
||||
except ValueError: # binascii.Error — подкласс ValueError
|
||||
raise ApiError(400, "bad_request", "content — не base64") from None
|
||||
try:
|
||||
sha, size = files.put(hub.files_dir, data)
|
||||
sha, size = files.put(hub.files_dir, data, allow_empty=allow_empty)
|
||||
except files.EmptyFile:
|
||||
raise ApiError(422, "empty_file", "пустой файл") from None
|
||||
except files.TooLarge:
|
||||
@ -374,6 +382,55 @@ def h_files_post(hub, conn, m, q, body):
|
||||
return 200, {"file": sha, "size": size}
|
||||
|
||||
|
||||
def h_files_post(hub, conn, m, q, body):
|
||||
return _put_file(hub, body)
|
||||
|
||||
|
||||
def h_agent_files_post(hub, conn, m, q, body):
|
||||
"""Файл от агента (fetch_attachment, решение 5): как POST /files, но пустой файл принимается (решение 13)."""
|
||||
return _put_file(hub, body, allow_empty=True)
|
||||
|
||||
|
||||
def _attachment(hub, conn, m):
|
||||
att = attachments.get(conn, hub.files_dir, m.group(1))
|
||||
if att is None:
|
||||
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу")
|
||||
return att
|
||||
|
||||
|
||||
def h_attachment_get(hub, conn, m, q, body):
|
||||
return 200, _attachment(hub, conn, m)
|
||||
|
||||
|
||||
def h_attachment_fetch(hub, conn, m, q, body):
|
||||
author = body.get("author") or "api"
|
||||
if not isinstance(author, str):
|
||||
raise ApiError(422, "bad_request", "author — строка")
|
||||
try:
|
||||
return 200, queue.request_fetch(conn, hub.files_dir, m.group(1), author)
|
||||
except KeyError:
|
||||
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None
|
||||
except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17)
|
||||
raise ApiError(413, "too_large", str(e)) from None
|
||||
except queue.AgentTooOld as e:
|
||||
raise ApiError(409, "agent_too_old", str(e)) from None
|
||||
except CommandError as e:
|
||||
raise ApiError(422, "bad_batch", str(e)) from None
|
||||
|
||||
|
||||
def h_attachment_content(hub, conn, m, q, body):
|
||||
"""Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987)."""
|
||||
att = _attachment(hub, conn, m)
|
||||
row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone()
|
||||
data = files.read(hub.files_dir, row["file"]) if att["state"] == "ready" and row else None
|
||||
if data is None:
|
||||
raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание")
|
||||
files.touch(hub.files_dir, row["file"])
|
||||
mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream"
|
||||
return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""),
|
||||
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
def h_command_retry(hub, conn, m, q, body):
|
||||
if not isinstance(body.get("author") or "api", str):
|
||||
raise ApiError(422, "bad_retry", "author — строка")
|
||||
@ -496,6 +553,7 @@ ROUTES = [
|
||||
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
|
||||
("GET", r"^/agent/release$", h_agent_release, "agent"),
|
||||
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
|
||||
("POST", r"^/agent/files$", h_agent_files_post, "agent"),
|
||||
("GET", r"^/issues$", h_issues, "client"),
|
||||
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
|
||||
("GET", r"^/board$", h_board, "client"),
|
||||
@ -506,6 +564,9 @@ ROUTES = [
|
||||
("GET", r"^/batches/(\d+)$", h_batch_get, "client"),
|
||||
("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),
|
||||
("POST", r"^/files$", h_files_post, "client"),
|
||||
("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"),
|
||||
("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"),
|
||||
("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "client"),
|
||||
("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"),
|
||||
("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"),
|
||||
("POST", r"^/refresh$", h_refresh, "client"),
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
@ -65,12 +66,12 @@ def _intact(target, sha) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def put(root, data: bytes) -> tuple[str, int]:
|
||||
def put(root, data: bytes, allow_empty: bool = False) -> tuple[str, int]:
|
||||
"""Сохранить содержимое, вернуть (sha256, размер). Целая копия уже есть (тот же sha256 содержимого) — только обновить
|
||||
mtime (уборка считает возраст от последней загрузки); оборванная или испорченная — переписать (решение 13). Запись
|
||||
атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется
|
||||
(решение 11)."""
|
||||
if not data:
|
||||
(решение 11). allow_empty — пустое содержимое принимается (файл от агента: в Jira бывают пустые вложения, решение 13)."""
|
||||
if not data and not allow_empty:
|
||||
raise EmptyFile("пустой файл")
|
||||
if len(data) > MAX_SIZE:
|
||||
raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})")
|
||||
@ -97,6 +98,37 @@ def put(root, data: bytes) -> tuple[str, int]:
|
||||
return sha, len(data)
|
||||
|
||||
|
||||
def touch(root, sha) -> None:
|
||||
"""Обновить mtime файла (выдача содержимого продлевает ему жизнь, решение 14); файла нет или sha негоден — ничего."""
|
||||
if not is_sha(sha):
|
||||
return
|
||||
try:
|
||||
os.utime(path(root, sha))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def sweep_downloads(root, now: float | None = None) -> list[str]:
|
||||
"""Удалить каталоги копий MCP <root>/<attachment_id>/, в которых ничего не менялось KEEP_DAYS (новее из mtime каталога
|
||||
и его файлов); вернуть имена. Файлы прямо в корне и сбой удаления одного каталога уборку не прерывают."""
|
||||
root = Path(root)
|
||||
if not root.is_dir():
|
||||
return []
|
||||
cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400
|
||||
removed = []
|
||||
for d in sorted(root.iterdir()):
|
||||
try:
|
||||
if d.is_symlink() or not d.is_dir():
|
||||
continue
|
||||
newest = max([d.stat().st_mtime] + [f.stat().st_mtime for f in d.iterdir() if f.is_file()])
|
||||
if newest < cutoff:
|
||||
shutil.rmtree(d)
|
||||
removed.append(d.name)
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def sweep(conn, root, now: float | None = None) -> list[str]:
|
||||
"""Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или
|
||||
claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят
|
||||
|
||||
@ -1030,3 +1030,142 @@ def test_issue_has_attachments(hub):
|
||||
got = c.get("/issues/A-1")
|
||||
assert got["attachments"][0]["attachment_id"] == "7" and got["attachments"][0]["state"] == "none"
|
||||
assert got["description_attachment_ids"] == ["7"]
|
||||
|
||||
|
||||
# --- вложения из Jira по запросу (#83) ----------------------------------------------
|
||||
|
||||
ATT_NAME = "скрин 1.png"
|
||||
|
||||
|
||||
def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME):
|
||||
conn = hub["hub"].conn()
|
||||
try:
|
||||
from jira_hub import mirror
|
||||
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
|
||||
{"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _content(hub, path):
|
||||
req = urllib.request.Request(hub["base"] + path, headers={"Authorization": "Bearer " + hub["client"].token})
|
||||
return urllib.request.urlopen(req)
|
||||
|
||||
|
||||
def _fetch_done(hub, data=b"abc"):
|
||||
"""Запрос скачивания, claim агентом, загрузка файла и отчёт done."""
|
||||
out = hub["client"].post("/attachments/31337/fetch", {"author": "web"})
|
||||
cmd = hub["agent"].post("/agent/claim", {})["command"]
|
||||
up = hub["agent"].post("/agent/files", {"content": _b64(data)})
|
||||
hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": up})
|
||||
return out, cmd, up
|
||||
|
||||
|
||||
def test_agent_files_post(hub):
|
||||
a = hub["agent"]
|
||||
out = a.post("/agent/files", {"content": _b64(b"abc")})
|
||||
assert out == {"file": hashlib.sha256(b"abc").hexdigest(), "size": 3}
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].post("/agent/files", {"content": _b64(b"abc")})
|
||||
assert e.value.status == 401
|
||||
assert a.post("/agent/files", {"content": ""}) == {"file": hashlib.sha256(b"").hexdigest(), "size": 0}
|
||||
with pytest.raises(HubError) as e:
|
||||
a.post("/agent/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))})
|
||||
assert e.value.status == 413 and e.value.error == "too_large"
|
||||
with pytest.raises(HubError) as e:
|
||||
a.post("/agent/files", {"content": "***"})
|
||||
assert e.value.status == 400
|
||||
|
||||
|
||||
def test_attachment_get_404(hub):
|
||||
for method, path in (("get", "/attachments/1"), ("post", "/attachments/1/fetch"), ("get", "/attachments/1/content")):
|
||||
with pytest.raises(HubError) as e:
|
||||
getattr(hub["client"], method)(path)
|
||||
assert e.value.status == 404 and e.value.error == "no_attachment"
|
||||
|
||||
|
||||
def test_attachment_fetch_flow(hub):
|
||||
_seed_attachment(hub)
|
||||
out, cmd, up = _fetch_done(hub)
|
||||
assert out["state"] == "fetching" and out["command_id"] and out["batch_id"]
|
||||
assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2
|
||||
got = hub["client"].get("/attachments/31337")
|
||||
assert got["state"] == "ready" and got["filename"] == ATT_NAME and got["key"] == "A-1"
|
||||
resp = _content(hub, "/attachments/31337/content")
|
||||
assert resp.status == 200 and resp.read() == b"abc"
|
||||
assert resp.headers["Content-Type"] == "image/png"
|
||||
assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png"
|
||||
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert resp.headers["Content-Length"] == "3"
|
||||
|
||||
|
||||
def test_attachment_content_updates_mtime(hub):
|
||||
_seed_attachment(hub)
|
||||
_, _, up = _fetch_done(hub)
|
||||
p = hub["hub"].files_dir / up["file"]
|
||||
old = time.time() - 10 * 86400
|
||||
os.utime(p, (old, old))
|
||||
_content(hub, "/attachments/31337/content").read()
|
||||
assert p.stat().st_mtime > time.time() - 60
|
||||
|
||||
|
||||
def test_attachment_content_special_name(hub):
|
||||
name = "a:b/c d\u00e9.txt"
|
||||
_seed_attachment(hub, name=name, mime="text/plain")
|
||||
_fetch_done(hub)
|
||||
resp = _content(hub, "/attachments/31337/content")
|
||||
assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''a%3Ab%2Fc%20d%C3%A9.txt"
|
||||
|
||||
|
||||
def test_attachment_content_not_fetched(hub):
|
||||
_seed_attachment(hub)
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].get("/attachments/31337/content")
|
||||
assert e.value.status == 404 and e.value.error == "not_fetched"
|
||||
|
||||
|
||||
def test_attachment_content_bad_mime(hub):
|
||||
_seed_attachment(hub, mime="weird")
|
||||
_fetch_done(hub)
|
||||
assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream"
|
||||
|
||||
|
||||
def test_attachment_fetch_twice_same_command(hub):
|
||||
_seed_attachment(hub)
|
||||
a = hub["client"].post("/attachments/31337/fetch", {})
|
||||
b = hub["client"].post("/attachments/31337/fetch", {})
|
||||
assert a["command_id"] == b["command_id"] and b["state"] == "fetching"
|
||||
|
||||
|
||||
def test_attachment_fetch_too_large_413(hub):
|
||||
_seed_attachment(hub, size=25_000_000)
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].post("/attachments/31337/fetch", {})
|
||||
assert e.value.status == 413 and e.value.error == "too_large"
|
||||
assert e.value.detail == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira"
|
||||
assert hub["client"].get("/batches")["batches"] == []
|
||||
|
||||
|
||||
def test_attachment_fetch_agent_too_old_409(hub):
|
||||
_seed_attachment(hub)
|
||||
hub["agent"].post("/agent/claim", {"agent_version": 13})
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].post("/attachments/31337/fetch", {})
|
||||
assert e.value.status == 409 and e.value.error == "agent_too_old"
|
||||
assert e.value.detail == "агент v13 не умеет скачивать вложения — нужен v14"
|
||||
|
||||
|
||||
def test_sweep_files_downloads(hub):
|
||||
h = hub["hub"]
|
||||
d = h.downloads_dir / "31337"
|
||||
d.mkdir(parents=True)
|
||||
(d / "a.png").write_bytes(b"x")
|
||||
old = time.time() - 31 * 86400
|
||||
os.utime(d, (old, old))
|
||||
os.utime(d / "a.png", (old, old))
|
||||
conn = h.conn()
|
||||
try:
|
||||
h.sweep_files(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
assert not d.exists()
|
||||
|
||||
@ -155,3 +155,37 @@ def test_put_rewrites_corrupted_copy_of_same_size(root):
|
||||
(root / ABC).write_bytes(b"xyz") # тот же размер, другое содержимое
|
||||
assert files.put(root, b"abc") == (ABC, 3)
|
||||
assert (root / ABC).read_bytes() == b"abc"
|
||||
|
||||
|
||||
# --- allow_empty, touch, sweep_downloads (#83) ---------------------------------
|
||||
|
||||
def test_put_allow_empty(root):
|
||||
assert files.put(root, b"", allow_empty=True) == (hashlib.sha256(b"").hexdigest(), 0)
|
||||
with pytest.raises(files.EmptyFile):
|
||||
files.put(root, b"")
|
||||
|
||||
|
||||
def test_touch_updates_mtime(root):
|
||||
sha, _ = files.put(root, b"abc")
|
||||
old = time.time() - 5 * DAY
|
||||
os.utime(root / sha, (old, old))
|
||||
files.touch(root, sha)
|
||||
assert (root / sha).stat().st_mtime > time.time() - 60
|
||||
files.touch(root, "0" * 64) # файла нет — не ошибка
|
||||
files.touch(root, "../x") # негодный sha — не ошибка
|
||||
|
||||
|
||||
def test_sweep_downloads(tmp_path):
|
||||
root = tmp_path / "downloads"
|
||||
old, fresh = root / "31337", root / "31338"
|
||||
for d in (old, fresh):
|
||||
d.mkdir(parents=True)
|
||||
(d / "a.png").write_bytes(b"x")
|
||||
(root / "loose.txt").write_text("x")
|
||||
t = time.time() - 31 * DAY
|
||||
os.utime(old / "a.png", (t, t))
|
||||
os.utime(old, (t, t))
|
||||
os.utime(root / "loose.txt", (t, t))
|
||||
assert files.sweep_downloads(root) == ["31337"]
|
||||
assert not old.exists() and fresh.exists() and (root / "loose.txt").exists()
|
||||
assert files.sweep_downloads(tmp_path / "нет") == []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user