wiki: маршруты и вложения по запросу (#91)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cd8f5505b0
commit
a95230dc33
@ -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,
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
94
src/jira_hub/modules/wiki/attachments.py
Normal file
94
src/jira_hub/modules/wiki/attachments.py
Normal file
@ -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")}
|
||||
@ -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)
|
||||
|
||||
48
tests/fixtures/baseline/routes.expected.json
vendored
48
tests/fixtures/baseline/routes.expected.json
vendored
@ -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"
|
||||
]
|
||||
]
|
||||
|
||||
218
tests/test_wiki_api.py
Normal file
218
tests/test_wiki_api.py
Normal file
@ -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"}
|
||||
Loading…
x
Reference in New Issue
Block a user