From a95230dc33ef592bd23796df7e8465cdc896c8d6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 02:40:33 +0400 Subject: [PATCH] =?UTF-8?q?wiki:=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=83=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/modules/wiki/__init__.py | 3 +- src/jira_hub/modules/wiki/api.py | 143 +++++++++++- src/jira_hub/modules/wiki/attachments.py | 94 ++++++++ src/jira_hub/modules/wiki/commands.py | 30 ++- tests/fixtures/baseline/routes.expected.json | 48 ++++ tests/test_wiki_api.py | 218 +++++++++++++++++++ 6 files changed, 529 insertions(+), 7 deletions(-) create mode 100644 src/jira_hub/modules/wiki/attachments.py create mode 100644 tests/test_wiki_api.py diff --git a/src/jira_hub/modules/wiki/__init__.py b/src/jira_hub/modules/wiki/__init__.py index 1c4418a..ed96c77 100644 --- a/src/jira_hub/modules/wiki/__init__.py +++ b/src/jira_hub/modules/wiki/__init__.py @@ -1,6 +1,6 @@ """Модуль wiki: Markdown-копия вики Eva по проектам, поиск, вложения по запросу.""" from ...core.modules import Module -from . import api, commands, projects, schema, settings, tick +from . import api, attachments, commands, projects, schema, settings, tick MODULE = Module( name="wiki", @@ -11,6 +11,7 @@ MODULE = Module( validate_setting=settings.validate_setting, commands=commands.HOOKS, routes=api.ROUTES, + file_refs=attachments.file_refs, tick=(tick.housekeeping_tick, tick.auto_sync_tick), project_fields=projects.FIELDS, project_view=projects.view, diff --git a/src/jira_hub/modules/wiki/api.py b/src/jira_hub/modules/wiki/api.py index 1df7255..269ce7c 100644 --- a/src/jira_hub/modules/wiki/api.py +++ b/src/jira_hub/modules/wiki/api.py @@ -1,11 +1,150 @@ -"""HTTP модуля wiki: маршруты агента (приём пачек страниц).""" -from . import stage +"""HTTP модуля wiki: маршруты клиента (состояние, дерево, страница, поиск, синхронизация, вложения) и агента.""" +import json +from urllib.parse import unquote + +from ...core import db, files, queue +from ...core.http import ApiError, Raw, int_group, not_found, project_param +from . import attachments, commands, paths, search, stage + +MIME = "application/octet-stream" # типа в базе вики нет; скачивание — всегда вложением, без угадывания def h_agent_pages(hub, conn, m, q, body): return 200, stage.accept(conn, body.get("command_id"), body.get("pages")) +def _project(conn, q) -> tuple[str, dict]: + """(метка, строка wiki_project): без project — 422, неизвестный — 404, без корня — 409 no_root.""" + label = project_param(conn, q) + if label is None: + raise ApiError(422, "bad_request", "нужен параметр project=<метка>") + row = conn.execute("SELECT * FROM wiki_project WHERE label = ?", (label,)).fetchone() + if row is None: + raise ApiError(409, "no_root", f"у проекта {label} нет корня вики") + return label, row + + +def _batch(conn, label): + b = commands.active_sync(conn, label) + if b is None: + row = conn.execute("SELECT id FROM batch b WHERE b.project = ? AND EXISTS (SELECT 1 FROM command c WHERE " + "c.batch_id = b.id AND c.type = 'wiki_sync') ORDER BY b.id DESC LIMIT 1", (label,)).fetchone() + if row is None: + return None + b = queue.batch_view(conn, row["id"], with_commands=False) + return {"id": b["id"], "status": b["status"]} + + +def h_status(hub, conn, m, q, body): + label, r = _project(conn, q) + return 200, {"root": r["root_code"], "root_title": r["root_title"], "synced_at": r["synced_at"], + "pages": r["pages_count"], "error": r["sync_error"], "batch": _batch(conn, label)} + + +def _walk_order(conn, label) -> list[str]: + """Коды страниц в порядке обхода: по _tree.json копии, при его отсутствии — по пути.""" + rows = {r["code"]: r for r in conn.execute("SELECT code, path FROM wiki_page WHERE label = ?", (label,))} + try: + tree = json.loads((paths.project_dir(conn, label) / "_tree.json").read_text(encoding="utf-8")) + order = [t["code"] for t in tree if t["code"] in rows] + if len(order) == len(rows): + return order + except (OSError, ValueError, RuntimeError, KeyError, TypeError): + pass + return sorted(rows, key=lambda c: (rows[c]["path"] or "").split("/")) + + +def h_tree(hub, conn, m, q, body): + label, _ = _project(conn, q) + rows = {r["code"]: r for r in conn.execute("SELECT * FROM wiki_page WHERE label = ?", (label,))} + return 200, {"pages": [{"code": c, "title": rows[c]["title"], "depth": rows[c]["depth"], + "parent_code": rows[c]["parent_code"], "size": rows[c]["size"]} for c in _walk_order(conn, label)]} + + +def h_page(hub, conn, m, q, body): + label, _ = _project(conn, q) + code = unquote(m.group(1)) + r = not_found(conn.execute("SELECT * FROM wiki_page WHERE label = ? AND code = ?", (label, code)).fetchone(), f"страница {code}") + try: + text = (paths.project_dir(conn, label) / r["path"] / "index.md").read_text(encoding="utf-8") + except (OSError, RuntimeError, TypeError): + raise ApiError(404, "not_found", f"файл страницы {code} не найден в копии") from None + if text.startswith("---\n"): # front matter копии клиенту не нужен + end = text.find("\n---\n", 4) + text = text[end + 5:].lstrip("\n") if end != -1 else text + atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"]} for a in conn.execute( + "SELECT id, name, state, size FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))] + return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"], + "markdown": text, "attachments": atts} + + +def h_search(hub, conn, m, q, body): + label, _ = _project(conn, q) + text = q.get("q") or "" + if not text.strip(): + raise ApiError(422, "bad_query", "q — непустой запрос") + try: + limit = int(q.get("limit", 10)) + except (TypeError, ValueError): + raise ApiError(422, "bad_limit", "limit — целое от 1 до 50") from None + if not 1 <= limit <= 50: + raise ApiError(422, "bad_limit", "limit — целое от 1 до 50") + return 200, {"results": search.query(conn, label, text, limit)} + + +def h_sync(hub, conn, m, q, body): + q = dict(q, project=body.get("project")) + author = body.get("author") or "api" + if not isinstance(author, str): + raise ApiError(422, "bad_request", "author — строка") + with db.transaction(conn): + label, r = _project(conn, q) + active = commands.active_sync(conn, label) + if active is not None: + raise ApiError(409, "sync_in_progress", {"batch_id": active["id"]}) + batch = commands.enqueue_sync(conn, label, r["root_code"], author) + return 201, {"batch_id": batch["id"]} + + +def _attachment(conn, m) -> dict: + return not_found(attachments.get(conn, int_group(m)), f"вложение {m.group(1)}") + + +def h_attachment_get(hub, conn, m, q, body): + return 200, _attachment(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: + info = attachments.request_fetch(conn, int_group(m), author) + except KeyError: + raise ApiError(404, "not_found", f"вложение {m.group(1)}") from None + queued = info.pop("queued") + return (202 if queued else 200), info + + +def h_attachment_content(hub, conn, m, q, body): + att = _attachment(conn, m) + row = conn.execute("SELECT file FROM wiki_attachment WHERE id = ?", (att["id"],)).fetchone() + data = files.read(hub.files_dir, row["file"]) if att["state"] == "ready" else None + if data is None: + raise ApiError(409, "not_ready", "вложение не скачано — запросите скачивание") + files.touch(hub.files_dir, row["file"]) + return 200, Raw(MIME, data, {"Content-Disposition": attachments.disposition(attachments.file_name(att)), + "X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"}) + + ROUTES = ( ("POST", r"^/agent/wiki/pages$", h_agent_pages, "agent"), + ("GET", r"^/wiki/status$", h_status, "client"), + ("GET", r"^/wiki/tree$", h_tree, "client"), + ("GET", r"^/wiki/pages/([^/]+)$", h_page, "client"), + ("GET", r"^/wiki/search$", h_search, "client"), + ("POST", r"^/wiki/sync$", h_sync, "client"), + ("GET", r"^/wiki/attachments/(\d+)$", h_attachment_get, "client"), + ("POST", r"^/wiki/attachments/(\d+)/fetch$", h_attachment_fetch, "client"), + ("GET", r"^/wiki/attachments/(\d+)/content$", h_attachment_content, "client"), ) diff --git a/src/jira_hub/modules/wiki/attachments.py b/src/jira_hub/modules/wiki/attachments.py new file mode 100644 index 0000000..1b1f618 --- /dev/null +++ b/src/jira_hub/modules/wiki/attachments.py @@ -0,0 +1,94 @@ +"""Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»). +Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит.""" +import json +from urllib.parse import quote + +from ...core import db, files, queue + +FIELDS = "id, label, page_code, url, name, state, file, size, error" + + +def _public(row) -> dict: + return {"id": row["id"], "name": row["name"], "page_code": row["page_code"], "url": row["url"], + "state": row["state"], "size": row["size"], "error": row["error"]} + + +def get(conn, attachment_id: int) -> dict | None: + row = conn.execute(f"SELECT {FIELDS} FROM wiki_attachment WHERE id = ?", (attachment_id,)).fetchone() + return _public(row) if row else None + + +def file_name(att: dict) -> str: + """Имя для скачивания: из недоверенных данных Eva; пустое — запасное.""" + return (att.get("name") or "").strip() or f"attachment-{att['id']}" + + +def disposition(name: str) -> str: + """Content-Disposition с именем по RFC 5987: quote(safe="") кодирует CR/LF, кавычки, «;» и любые управляющие.""" + return "attachment; filename*=UTF-8''" + quote(name, safe="") + + +def _active(conn, attachment_id: int) -> bool: + """Есть ли выданная или ожидающая команда wiki_fetch этого вложения.""" + return conn.execute("SELECT 1 FROM command WHERE type = 'wiki_fetch' AND status IN ('queued', 'claimed') " + "AND json_extract(params, '$.attachment_id') = ?", (attachment_id,)).fetchone() is not None + + +def request_fetch(conn, attachment_id: int, author: str | None) -> dict: + """Поставить скачивание. Одна транзакция: `ready` и `fetching` отвечают как есть; иначе state = fetching и пакет + wiki_fetch. `fetching` без живой команды (пакет отменён) — зависшее состояние: ставится новый пакет. + Ответ — {id, name, page_code, url, state, size, error, queued}. KeyError — вложения нет.""" + with db.transaction(conn): + row = conn.execute(f"SELECT {FIELDS} FROM wiki_attachment WHERE id = ?", (attachment_id,)).fetchone() + if row is None: + raise KeyError(attachment_id) + if row["state"] == "ready" or (row["state"] == "fetching" and _active(conn, attachment_id)): + return dict(_public(row), queued=False) + title = f"Вложение вики {file_name(_public(row))}" + queue.insert_batch(conn, title, author, [{"type": "wiki_fetch", "params": {"attachment_id": row["id"], "url": row["url"]}}], + project=row["label"]) + conn.execute("UPDATE wiki_attachment SET state = 'fetching', error = NULL WHERE id = ?", (attachment_id,)) + return dict(_public(conn.execute(f"SELECT {FIELDS} FROM wiki_attachment WHERE id = ?", (attachment_id,)).fetchone()), + queued=True) + + +def check_result(conn, cmd, status, result, files_dir) -> tuple[str, str | None]: + """done требует файл в files/ с заявленным размером; иначе отчёт принимается как failed.""" + if status != "done": + return status, None + sha = result.get("file") if isinstance(result, dict) else None + size = result.get("size") if isinstance(result, dict) else None + real = files.size(files_dir, sha) if files_dir is not None else None + if real is None: + return "failed", f"хаб: файл {sha} не найден в хранилище" + if isinstance(size, bool) or real != size: + return "failed", f"хаб: размер файла {real} не совпал с заявленным {size}" + return status, None + + +def _error(conn, cmd) -> str: + row = conn.execute("SELECT error FROM command WHERE id = ?", (cmd["id"],)).fetchone() + return ((row["error"] if row else None) or "скачивание не удалось")[:1500] + + +def _failed(conn, cmd, error) -> None: + conn.execute("UPDATE wiki_attachment SET state = 'failed', error = ? WHERE id = ?", + (error, cmd["params"]["attachment_id"])) + + +def on_result(conn, cmd, status, result, now) -> None: + aid = cmd["params"]["attachment_id"] + if status == "done": # check_result уже сверил файл и размер + conn.execute("UPDATE wiki_attachment SET state = 'ready', file = ?, size = ?, error = NULL, fetched_at = ? WHERE id = ?", + (result["file"], result["size"], now, aid)) + else: + _failed(conn, cmd, _error(conn, cmd)) + + +def on_failed(conn, cmd, now) -> None: + _failed(conn, cmd, _error(conn, cmd)) + + +def file_refs(conn) -> set[str]: + """sha файлов вложений в ready: уборка files/ их не трогает.""" + return {r["file"] for r in conn.execute("SELECT file FROM wiki_attachment WHERE state = 'ready' AND file IS NOT NULL")} diff --git a/src/jira_hub/modules/wiki/commands.py b/src/jira_hub/modules/wiki/commands.py index 964fcc0..c105594 100644 --- a/src/jira_hub/modules/wiki/commands.py +++ b/src/jira_hub/modules/wiki/commands.py @@ -4,7 +4,7 @@ import re from ...core import queue from ...core.errors import CommandError from ...core.modules import CommandHooks -from . import sync +from . import attachments, sync TYPES = {"wiki_sync", "wiki_fetch"} SPEC = {"wiki_sync": {"required": ["label", "root"], "optional": []}, @@ -69,13 +69,35 @@ def active_sync(conn, label: str) -> dict | None: return queue.batch_view(conn, row["id"], with_commands=False) if row else None -# wiki_fetch: хуки результата заполнит Task 8; wiki_sync — sync.py +def on_failed(conn, cmd, now) -> None: + (attachments if cmd["type"] == "wiki_fetch" else sync).on_failed(conn, cmd, now) + + +def check_result(conn, cmd, status, result, files_dir) -> tuple[str, str | None]: + if cmd["type"] == "wiki_fetch": + return attachments.check_result(conn, cmd, status, result, files_dir) + return sync.check_result(conn, cmd, status, result, files_dir) + + +def on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir) -> None: + if cmd["type"] == "wiki_fetch": + attachments.on_result(conn, cmd, status, result, now) + else: + sync.on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir) + + +def after_result(conn, cmd, status) -> None: + if cmd["type"] == "wiki_sync": + sync.after_result(conn, cmd, status) + + +# wiki_sync — sync.py (хуки before/abort/after только у него), wiki_fetch — attachments.py HOOKS = CommandHooks( types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch, tail=lambda cmds: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda ctype, params, mapping: params, drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], - check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=sync.on_failed, - check_result=sync.check_result, on_result=sync.on_result, after_result=sync.after_result, + check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=on_failed, + check_result=check_result, on_result=on_result, after_result=after_result, on_late_result=lambda *a: False, before_result=sync.before_result, abort_result=sync.abort_result) diff --git a/tests/fixtures/baseline/routes.expected.json b/tests/fixtures/baseline/routes.expected.json index 3c6a85c..bf83729 100644 --- a/tests/fixtures/baseline/routes.expected.json +++ b/tests/fixtures/baseline/routes.expected.json @@ -220,5 +220,53 @@ "^/sync-runs$", "client", "h_sync_runs" + ], + [ + "GET", + "^/wiki/attachments/(\\d+)$", + "client", + "h_attachment_get" + ], + [ + "GET", + "^/wiki/attachments/(\\d+)/content$", + "client", + "h_attachment_content" + ], + [ + "POST", + "^/wiki/attachments/(\\d+)/fetch$", + "client", + "h_attachment_fetch" + ], + [ + "GET", + "^/wiki/pages/([^/]+)$", + "client", + "h_page" + ], + [ + "GET", + "^/wiki/search$", + "client", + "h_search" + ], + [ + "GET", + "^/wiki/status$", + "client", + "h_status" + ], + [ + "POST", + "^/wiki/sync$", + "client", + "h_sync" + ], + [ + "GET", + "^/wiki/tree$", + "client", + "h_tree" ] ] diff --git a/tests/test_wiki_api.py b/tests/test_wiki_api.py new file mode 100644 index 0000000..032814c --- /dev/null +++ b/tests/test_wiki_api.py @@ -0,0 +1,218 @@ +"""Маршруты вики и вложения по запросу (wiki-module, spec wiki «Маршруты вики», «Вложения вики по запросу»). +Агента имитирует тест токеном agent wiki (фикстура api из test_wiki_sync).""" +import base64 +import os +import urllib.error +import urllib.request +from urllib.parse import quote + +import pytest + +from jira_hub.core import files +from jira_hub.core.client import HubError +from test_wiki_convert import FILE_REF, FILE_URL, THUMB_REF, _html +from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура + +ATTS = [{"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}, {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}] + + +def synced(api, name_html=None, attachments=None): + """Синхронизация дерева, где у DOC-3 есть вложение model.xlsx; возвращает id вложения.""" + html = name_html if name_html is not None else _html("page_file.html") + tree = [dict(p) for p in TREE] + tree[2] = page("DOC-3", "Статусы", "DOC-1", 1, html, attachments=[{"url": FILE_URL, "name": "model.xlsx"}] if attachments is None + else attachments) + # вложения присылает агент уже разобранными: ref → url, как в test_wiki_sync + tree[2]["attachments"] = ATTS if attachments is None else attachments + assert api.run(tree, CODES)["status"] == "done" + return api.rows("SELECT id FROM wiki_attachment ORDER BY id")[0]["id"] + + +def upload(api, data: bytes) -> dict: + return api.wiki.post("/agent/files", {"content": base64.b64encode(data).decode()}) + + +def fetch_cmd(api): + cmd = api.claim() + assert cmd and cmd["type"] == "wiki_fetch", cmd + return cmd + + +def content(api, aid): + req = urllib.request.Request(f"{api.base}/wiki/attachments/{aid}/content", headers={"Authorization": "Bearer " + api.client.token}) + return urllib.request.urlopen(req) + + +def err(fn, *a, **kw): + with pytest.raises((HubError, urllib.error.HTTPError)) as e: + fn(*a, **kw) + if isinstance(e.value, urllib.error.HTTPError): + e.value.close() # иначе ResourceWarning о незакрытом сокете + return e.value + + +def test_status_tree_page(api): + aid = synced(api) + st = api.client.get("/wiki/status", project="AWPCA") + assert st["root"] == "DOC-1" and st["root_title"] == "АРМ КА" and st["pages"] == 3 and st["error"] is None + assert st["synced_at"] and st["batch"]["status"] == "done" + tree = api.client.get("/wiki/tree", project="AWPCA")["pages"] + assert [(n["code"], n["depth"], n["parent_code"]) for n in tree] == [("DOC-1", 0, None), ("DOC-3", 1, "DOC-1"), ("DOC-2", 1, "DOC-1")] + assert all(set(n) == {"code", "title", "depth", "parent_code", "size"} for n in tree) + p = api.client.get("/wiki/pages/DOC-3", project="AWPCA") + assert p["title"] == "Статусы" and p["url"].endswith("DOC-3") and p["synced_at"] + assert f"attachment:{aid}" in p["markdown"] and not p["markdown"].startswith("---") + assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None}] + assert err(api.client.get, "/wiki/pages/DOC-99", project="AWPCA").status == 404 + + +def test_page_code_with_slash(api): + first_sync(api) + c = api.hub.conn() # код с «/» бывает только в адресе как %2F + c.execute("UPDATE wiki_page SET code = 'A/B' WHERE code = 'DOC-2'") + c.commit() + c.close() + assert api.client.get("/wiki/pages/" + quote("A/B", safe=""), project="AWPCA")["code"] == "A/B" + + +def test_no_root(api): + api.client.post("/projects", {"label": "sfm_kedr", "name": "СФМ"}) + assert err(api.client.get, "/wiki/tree", project="sfm_kedr").error == "no_root" + assert err(api.client.get, "/wiki/status", project="sfm_kedr").status == 409 + assert err(api.client.get, "/wiki/tree", project="нет").status == 404 + assert err(api.client.get, "/wiki/tree").status == 422 + assert err(api.client.post, "/wiki/sync", {"project": "sfm_kedr"}).error == "no_root" + + +def test_sync_in_progress(api): + first_sync(api) + r = api.client.post("/wiki/sync", {"project": "AWPCA", "author": "t"}) + assert set(r) == {"batch_id"} + e = err(api.client.post, "/wiki/sync", {"project": "AWPCA"}) + assert e.status == 409 and e.error == "sync_in_progress" and e.detail["batch_id"] == r["batch_id"] + assert api.client.get("/wiki/status", project="AWPCA")["batch"] == {"id": r["batch_id"], "status": "queued"} + assert len(api.rows("SELECT id FROM batch WHERE project = 'AWPCA'")) == 2 + + +def test_fetch_flow(api): + aid = synced(api) + data = b"\x00xlsx-bytes" * 100 + r = api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + assert r == {"id": aid, "name": "model.xlsx", "page_code": "DOC-3", "url": FILE_URL, "state": "fetching", "size": None, "error": None} + cmd = fetch_cmd(api) + assert cmd["params"] == {"attachment_id": aid, "url": FILE_URL} + assert err(content, api, aid).status == 409 + f = upload(api, data) + api.report(cmd, "done", {"file": f["file"], "size": f["size"]}) + st = api.client.get(f"/wiki/attachments/{aid}") + assert st["state"] == "ready" and st["size"] == len(data) and st["error"] is None + with content(api, aid) as resp: + assert resp.read() == data + assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''model.xlsx" + assert resp.headers["Content-Type"] == "application/octet-stream" and resp.headers["X-Content-Type-Options"] == "nosniff" + again = api.client.post(f"/wiki/attachments/{aid}/fetch", {}) # ready — как есть, нового пакета нет + assert again["state"] == "ready" and len(api.rows("SELECT id FROM command WHERE type = 'wiki_fetch'")) == 1 + + +def test_fetch_repeat_while_fetching(api): + aid = synced(api) + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + r = api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + assert r["state"] == "fetching" and len(api.rows("SELECT id FROM command WHERE type = 'wiki_fetch'")) == 1 + + +def test_fetch_requeues_after_cancel(api): + aid = synced(api) + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + batch = api.rows("SELECT batch_id FROM command WHERE type = 'wiki_fetch'")[0]["batch_id"] + api.client.post(f"/batches/{batch}/cancel", {}) + assert api.client.get(f"/wiki/attachments/{aid}")["state"] == "fetching" + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) # зависшее fetching — новый пакет + assert len(api.rows("SELECT id FROM command WHERE type = 'wiki_fetch' AND status = 'queued'")) == 1 + + +def test_fetch_too_big(api): + aid = synced(api) + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + api.report(fetch_cmd(api), "failed", error="файл больше 10 МБ — откройте в Eva") + st = api.client.get(f"/wiki/attachments/{aid}") + assert st["state"] == "failed" and st["error"] == "файл больше 10 МБ — откройте в Eva" + e = err(content, api, aid) + assert e.status == 409 + assert api.client.post(f"/wiki/attachments/{aid}/fetch", {})["state"] == "fetching" # из failed — повтор + + +def test_fetch_size_mismatch(api): + aid = synced(api) + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + cmd = fetch_cmd(api) + f = upload(api, b"abc") + api.report(cmd, "done", {"file": f["file"], "size": f["size"] + 1}) + st = api.client.get(f"/wiki/attachments/{aid}") + assert st["state"] == "failed" and "размер" in st["error"] + assert err(content, api, aid).status == 409 + + +def test_fetch_file_missing(api): + aid = synced(api) + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + api.report(fetch_cmd(api), "done", {"file": "0" * 64, "size": 5}) + assert api.client.get(f"/wiki/attachments/{aid}")["state"] == "failed" + + +def test_fetch_unknown_attachment(api): + first_sync(api) + assert err(api.client.post, "/wiki/attachments/999/fetch", {}).status == 404 + assert err(api.client.get, "/wiki/attachments/999").status == 404 + assert err(content, api, 999).status == 404 + assert api.rows("SELECT id FROM batch WHERE title LIKE 'Вложение%'") == [] + + +def test_fetch_url_not_from_client(api): + aid = synced(api) + api.client.post(f"/wiki/attachments/{aid}/fetch", {"url": "https://evil.example/x", "author": "t"}) + assert fetch_cmd(api)["params"]["url"] == FILE_URL + + +def test_content_name_is_safe(api): + aid = synced(api) + c = api.hub.conn() + f = upload(api, b"x") + evil = 'a"\r\nSet-Cookie: x=1;/../é.txt' + c.execute("UPDATE wiki_attachment SET state = 'ready', file = ?, size = 1, name = ? WHERE id = ?", (f["file"], evil, aid)) + c.commit() + c.close() + with content(api, aid) as resp: + disp = resp.headers["Content-Disposition"] + assert disp == "attachment; filename*=UTF-8''" + quote(evil, safe="") + assert "\r" not in disp and "\n" not in disp and resp.headers["Set-Cookie"] is None + + +def test_file_refs_keep_ready(api): + aid = synced(api) + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + cmd = fetch_cmd(api) + f = upload(api, b"keep me") + stale = upload(api, b"drop me") + api.report(cmd, "done", {"file": f["file"], "size": f["size"]}) + for sha in (f["file"], stale["file"]): + os.utime(files.path(api.hub.files_dir, sha), (1, 1)) # старше KEEP_DAYS + c = api.hub.conn() + removed = files.sweep(c, api.hub.files_dir) + c.close() + assert stale["file"] in removed and f["file"] not in removed + assert files.exists(api.hub.files_dir, f["file"]) + + +def test_search_empty_query(api): + first_sync(api) + assert err(api.client.get, "/wiki/search", project="AWPCA", q="").error == "bad_query" + assert err(api.client.get, "/wiki/search", project="AWPCA", q=" ").status == 422 + assert err(api.client.get, "/wiki/search", project="AWPCA", q="x", limit=0).status == 422 + assert err(api.client.get, "/wiki/search", project="AWPCA", q="x", limit=51).status == 422 + + +def test_search_results(api): + first_sync(api) + r = api.client.get("/wiki/search", project="AWPCA", q="согласов")["results"] + assert [x["code"] for x in r] == ["DOC-3"] and set(r[0]) == {"code", "title", "path", "snippet"}