Команда wiki_sync (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
243b4b8f8d
commit
9793fdd40b
@ -162,3 +162,18 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге
|
||||
- В `tests/test_py39.py` в белый список stdlib добавлен `html` (`html.parser`).
|
||||
|
||||
Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации.
|
||||
|
||||
26. **Обход дерева `wiki_sync` ведётся по коду страницы, а не по `id` (уточнение решения 5).**
|
||||
- `children`, `seen` и `parent_code` ключуются кодом (`ref.code`): код уникален и есть в каждой ссылке `parent`/`tree_parent`; `id` берётся только для `eva_id`.
|
||||
- Соседи сортируются по `(orderno is None, orderno, name без регистра, code)` — как `collect_subtree` экспорта; решение 5 говорит «name», хвост по коду добавлен для детерминизма.
|
||||
- Дерево с проектом — один список документов проекта; без проекта — обход в ширину через `tree_parent.code` (защита от циклов тоже по коду).
|
||||
|
||||
Обоснование: ссылка Eva в фейке и в экспорте гарантированно несёт `code`; поле `id` в ссылке может не совпасть с `id` документа. Цена ошибки: если коды в Eva неуникальны (не бывает), страницы склеятся.
|
||||
27. **Страница без названия получает `title = code`; корень без названия — `root_title = code`.**
|
||||
Обоснование: `stage._check` хаба отклоняет пустой `title` (422 `<код>: title`), и одна такая страница завалила бы всю синхронизацию. Цена ошибки: в хабе страница названа кодом вместо пустого имени.
|
||||
28. **Страница, исчезнувшая между списком и чтением (`get_doc` → `None`), — `failed` «страница <код> не найдена или нет прав»; частичная пропуск-синхронизация не делается.**
|
||||
Обоснование: отчёт `codes` обязан совпадать со стадией хаба; тихий пропуск скрыл бы потерю. Цена ошибки: из-за одной удалённой на лету страницы нужна повторная синхронизация.
|
||||
29. **Размер пачки считается тем же кодированием, что у `Http.call` (`ensure_ascii=False`, UTF-8) + 2 байта на разделитель; страница больше 4 МБ уходит одна пачкой (предел хаба 16 МБ).**
|
||||
Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ.
|
||||
30. **`fake_hub.POST /agent/wiki/pages` проверяет пачку как `stage.accept`/`_check` хаба (≤ 50 страниц, `command_id` целое, команда `wiki_sync` в статусе `claimed`, типы и пределы полей, форма `attachments`/`links`) и отвечает `{error, detail}` с 422/404/409.** Тесты `test_folder.py` приведены к валидным страницам и выданной команде.
|
||||
Обоснование: поручение владельца — фейк не мягче хаба. Цена ошибки: фейк строже или мягче хаба в мелочах; ловится e2e.
|
||||
|
||||
106
src/agent.py
106
src/agent.py
@ -663,6 +663,71 @@ def _parse_at(value) -> float:
|
||||
return dt.timestamp()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Дерево страниц Eva
|
||||
# ---------------------------------------------------------------------------
|
||||
MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES)
|
||||
MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ
|
||||
TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "orderno"]
|
||||
|
||||
|
||||
def _ref_code(ref) -> Optional[str]:
|
||||
"""Код из ссылки Eva {"id", "code"}; страница без родителя — None."""
|
||||
code = ref.get("code") if isinstance(ref, dict) else None
|
||||
return code if isinstance(code, str) and code else None
|
||||
|
||||
|
||||
def _orderno(value) -> Optional[int]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return int(value)
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
|
||||
def build_children(eva: "EvaClient", root: dict) -> Dict[str, List[dict]]:
|
||||
"""{код родителя: [дети]} для поддерева корня. С проектом — один список документов проекта и связи parent/tree_parent
|
||||
(tree_parent в Eva бывает не заполнен); без проекта — обход в ширину через фильтр tree_parent.code."""
|
||||
children = {} # type: Dict[str, List[dict]]
|
||||
project = _ref_code(root.get("project"))
|
||||
if project:
|
||||
for d in eva.list_docs([["project.code", "==", project]], TREE_FIELDS):
|
||||
for pc in {_ref_code(d.get("parent")), _ref_code(d.get("tree_parent"))} - {None}:
|
||||
children.setdefault(pc, []).append(d)
|
||||
else:
|
||||
queue, seen = [root], {root.get("code")}
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
kids = eva.list_docs([["tree_parent.code", "==", node["code"]]], TREE_FIELDS)
|
||||
children[node["code"]] = kids
|
||||
for k in kids:
|
||||
if k.get("code") not in seen:
|
||||
seen.add(k.get("code"))
|
||||
queue.append(k)
|
||||
for pc, kids in children.items():
|
||||
uniq = {k["code"]: k for k in kids if k.get("code")}
|
||||
children[pc] = sorted(uniq.values(), key=lambda k: (_orderno(k.get("orderno")) is None,
|
||||
_orderno(k.get("orderno")) or 0,
|
||||
(k.get("name") or "").lower(), k["code"]))
|
||||
return children
|
||||
|
||||
|
||||
def walk(root: dict, children: Dict[str, List[dict]]):
|
||||
"""Обход в глубину, корень первым: (документ, код родителя по обходу). Каждый код — один раз (циклы, два родителя)."""
|
||||
seen = set() # type: set
|
||||
stack = [(root, None)]
|
||||
while stack:
|
||||
doc, parent = stack.pop()
|
||||
code = doc["code"]
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
yield doc, parent
|
||||
for kid in reversed(children.get(code, [])):
|
||||
if kid["code"] not in seen:
|
||||
stack.append((kid, code))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Исполнитель: команда wiki_sync / wiki_fetch (читает Eva, отдаёт хабу)
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -713,7 +778,44 @@ class Executor:
|
||||
raise HubError(e) from None
|
||||
|
||||
def do_wiki_sync(self, p: dict, cmd: dict) -> dict:
|
||||
return self._out("failed", error="wiki_sync: не реализовано")
|
||||
"""Дерево страниц Eva от корня → хаб пачками; отчёт — коды в порядке обхода (корень первым). Eva только читается,
|
||||
поэтому после перезапуска команда выполняется заново целиком (design 9)."""
|
||||
root_code = str(p.get("root") or "").strip()
|
||||
if not root_code:
|
||||
return self._out("failed", error="wiki_sync: не задан root")
|
||||
root = self.eva.get_doc(root_code, TREE_FIELDS + ["text"])
|
||||
if not root:
|
||||
return self._out("failed", error="страница %s не найдена или нет прав" % root_code)
|
||||
children = build_children(self.eva, root)
|
||||
root_code = root.get("code") or root_code
|
||||
base = self.eva.base
|
||||
codes = [] # type: List[str]
|
||||
batch = [] # type: list
|
||||
size = 0
|
||||
for doc, parent_code in walk(root, children):
|
||||
code = doc["code"]
|
||||
full = root if code == root_code else self.eva.get_doc(code, ["code", "name", "text"])
|
||||
if not full:
|
||||
return self._out("failed", error="страница %s не найдена или нет прав" % code)
|
||||
html = full.get("text") if isinstance(full.get("text"), str) else ""
|
||||
attachments, links = classify_links(html, base)
|
||||
page = {"code": code, "eva_id": str(doc["id"]) if doc.get("id") is not None else None,
|
||||
# хаб отклоняет пустой title (stage._check): без названия страница называется своим кодом
|
||||
"title": (full.get("name") or doc.get("name") or "").strip() or code,
|
||||
"parent_code": parent_code, "orderno": _orderno(doc.get("orderno")),
|
||||
"url": urllib.parse.urljoin(base + "/", "project/Document/" + code),
|
||||
"html": html, "attachments": attachments, "links": links}
|
||||
psize = len(json.dumps(page, ensure_ascii=False).encode("utf-8")) + 2
|
||||
if batch and (len(batch) >= MAX_PAGES or size + psize > MAX_BATCH_BYTES):
|
||||
self._hub(self.hub.wiki_pages, cmd["id"], batch)
|
||||
batch, size = [], 0
|
||||
batch.append(page)
|
||||
size += psize
|
||||
codes.append(code)
|
||||
if batch:
|
||||
self._hub(self.hub.wiki_pages, cmd["id"], batch)
|
||||
title = (root.get("name") or "").strip() or root_code
|
||||
return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls})
|
||||
|
||||
def do_wiki_fetch(self, p: dict, cmd: dict) -> dict:
|
||||
return self._out("failed", error="wiki_fetch: не реализовано")
|
||||
@ -1001,7 +1103,7 @@ class Runner:
|
||||
return # остановка: finished не пишем, команду подхватит recover
|
||||
out["jira_calls"], out["rate_limited"] = calls, limited
|
||||
self.journal.finished(cid, out["status"], out.get("result"), out.get("error"), calls, limited)
|
||||
log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False)))
|
||||
log(" → %s %s" % (out["status"], (out.get("error") or json.dumps(out.get("result"), ensure_ascii=False))[:300]))
|
||||
rec = dict(out)
|
||||
rec["command_id"] = cid
|
||||
self.send_result(rec)
|
||||
|
||||
@ -9,6 +9,55 @@ import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
MAX_PAGES = 50
|
||||
|
||||
|
||||
def check_pages(command, command_id, pages):
|
||||
"""Форма пачки как в stage.accept/_check хаба: (код, тело ошибки) или None. 422 — {"error", "detail"}."""
|
||||
def bad(detail, status=422, error="bad_pages"):
|
||||
return status, {"error": error, "detail": detail}
|
||||
|
||||
if isinstance(command_id, bool) or not isinstance(command_id, int):
|
||||
return bad("command_id — целое", error="bad_request")
|
||||
if not isinstance(pages, list) or not pages or len(pages) > MAX_PAGES:
|
||||
return bad("pages — список от 1 до %d страниц" % MAX_PAGES)
|
||||
if command is None:
|
||||
return bad("command %s" % command_id, 404, "not_found")
|
||||
if command["type"] != "wiki_sync" or command["status"] != "claimed":
|
||||
return bad("команда %s не выдана агенту как wiki_sync" % command_id, 409, "not_claimed")
|
||||
limits = {"code": 200, "title": 1000, "url": 4000, "parent_code": 200, "eva_id": 200}
|
||||
for i, page in enumerate(pages, 1):
|
||||
if not isinstance(page, dict):
|
||||
return bad("страница %d: не объект" % i)
|
||||
code = page.get("code")
|
||||
if not isinstance(code, str) or not code.strip() or len(code) > limits["code"]:
|
||||
return bad("страница %d: code" % i)
|
||||
for key, required in (("title", True), ("parent_code", False), ("url", False)):
|
||||
v = page.get(key)
|
||||
if v is None and not required:
|
||||
continue
|
||||
if not isinstance(v, str) or (required and not v.strip()) or len(v) > limits[key]:
|
||||
return bad("%s: %s" % (code, key))
|
||||
if not isinstance(page.get("html"), str):
|
||||
return bad("%s: html" % code)
|
||||
o = page.get("orderno")
|
||||
if o is not None and (not isinstance(o, int) or isinstance(o, bool)):
|
||||
return bad("%s: orderno" % code)
|
||||
e = page.get("eva_id")
|
||||
if e is not None and (not isinstance(e, str) or len(e) > limits["eva_id"]):
|
||||
return bad("%s: eva_id" % code)
|
||||
att, links = page.get("attachments") or [], page.get("links") or []
|
||||
if not isinstance(att, list) or not all(
|
||||
isinstance(a, dict) and isinstance(a.get("ref"), str) and isinstance(a.get("url"), str)
|
||||
and isinstance(a.get("name") or "", str) for a in att):
|
||||
return bad("%s: attachments" % code)
|
||||
if not isinstance(links, list) or not all(
|
||||
isinstance(l, dict) and isinstance(l.get("ref"), str) and isinstance(l.get("code"), str)
|
||||
for l in links):
|
||||
return bad("%s: links" % code)
|
||||
return None
|
||||
|
||||
|
||||
class FakeHub:
|
||||
token = "agent-token"
|
||||
|
||||
@ -126,6 +175,10 @@ class FakeHub:
|
||||
if path == "/agent/wiki/pages" and method == "POST":
|
||||
if self.reject_pages:
|
||||
return self.reject_pages
|
||||
cid = body.get("command_id")
|
||||
bad = check_pages(self.commands.get(cid) if isinstance(cid, int) else None, cid, body.get("pages"))
|
||||
if bad:
|
||||
return bad
|
||||
self.pages.append(body)
|
||||
return 200, {"accepted": len(body.get("pages") or [])}
|
||||
if path == "/agent/files" and method == "POST":
|
||||
|
||||
@ -138,18 +138,26 @@ class FolderTransportTest(unittest.TestCase):
|
||||
self.assertIsNone(hub.folder)
|
||||
self.assertEqual(hub.settings()["settings_version"], 1)
|
||||
|
||||
def claimed_sync(self):
|
||||
cid = self.hub.enqueue("wiki_sync", {"label": "x", "root": "DOC-1"})
|
||||
self.hub.queue.remove(cid)
|
||||
self.hub.commands[cid]["status"] = "claimed"
|
||||
return cid
|
||||
|
||||
def test_wiki_pages_through_folder(self):
|
||||
self.run_relay()
|
||||
pages = [{"code": "DOC-1", "name": "Корень", "html": "<p>" + "я" * 200000 + "</p>"}]
|
||||
got = self.client().wiki_pages(5, pages)
|
||||
cid = self.claimed_sync()
|
||||
pages = [{"code": "DOC-1", "title": "Корень", "html": "<p>" + "я" * 200000 + "</p>"}]
|
||||
got = self.client().wiki_pages(cid, pages)
|
||||
self.assertEqual(got, {"accepted": 1})
|
||||
self.assertEqual(self.hub.pages, [{"command_id": 5, "pages": pages}])
|
||||
self.assertEqual(self.hub.pages, [{"command_id": cid, "pages": pages}])
|
||||
self.assertEqual(self.relay.requests[0]["path"], "/agent/wiki/pages")
|
||||
|
||||
def test_wiki_pages_over_http(self):
|
||||
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
|
||||
self.assertEqual(hub.wiki_pages(5, [{"code": "DOC-1"}]), {"accepted": 1})
|
||||
self.assertEqual(self.hub.pages[0]["command_id"], 5)
|
||||
cid = self.claimed_sync()
|
||||
self.assertEqual(hub.wiki_pages(cid, [{"code": "DOC-1", "title": "T", "html": ""}]), {"accepted": 1})
|
||||
self.assertEqual(self.hub.pages[0]["command_id"], cid)
|
||||
|
||||
def test_wiki_pages_rejected_is_http_error(self):
|
||||
self.hub.reject_pages = (422, {"error": "bad_page"})
|
||||
|
||||
199
tests/test_wiki_sync.py
Normal file
199
tests/test_wiki_sync.py
Normal file
@ -0,0 +1,199 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from _common import SETTINGS, agent, eva_cfg, no_sleep
|
||||
from fake_eva import FakeEva
|
||||
from fake_hub import FakeHub
|
||||
|
||||
|
||||
class SyncBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.eva = FakeEva()
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
self.addCleanup(self.eva.stop)
|
||||
self.addCleanup(self.hub.stop)
|
||||
self.cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||
self.client = agent.EvaClient(self.cfg)
|
||||
self.client.sleep = no_sleep
|
||||
self.ex = agent.Executor(self.client, agent.HubClient(self.cfg), dict(SETTINGS))
|
||||
|
||||
def sync(self, root="DOC-1", label="tz"):
|
||||
cid = self.hub.enqueue("wiki_sync", {"label": label, "root": root})
|
||||
self.hub.queue.remove(cid)
|
||||
self.hub.commands[cid]["status"] = "claimed"
|
||||
self.cid = cid
|
||||
return self.ex.run({"id": cid, "type": "wiki_sync", "params": {"label": label, "root": root}})
|
||||
|
||||
def sent(self):
|
||||
return [p for body in self.hub.pages for p in body["pages"]]
|
||||
|
||||
|
||||
class WikiSyncTest(SyncBase):
|
||||
def test_sync_tree_order(self):
|
||||
e = self.eva
|
||||
e.add_doc(1, "DOC-1", "Корень", project="P", text="<p>корень</p>")
|
||||
e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P", orderno=2, text="<p>б</p>")
|
||||
e.add_doc(3, "DOC-3", "А", parent="DOC-1", project="P", orderno=1, text="<p>а</p>")
|
||||
e.add_doc(4, "DOC-4", "Внук", parent="DOC-3", project="P", text="<p>внук</p>")
|
||||
e.add_doc(5, "DOC-5", "Чужой", parent="DOC-1", project="OTHER")
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["codes"], ["DOC-1", "DOC-3", "DOC-4", "DOC-2"])
|
||||
self.assertEqual(out["result"]["root_title"], "Корень")
|
||||
self.assertEqual(out["result"]["eva_calls"], self.client.calls)
|
||||
pages = {p["code"]: p for p in self.sent()}
|
||||
self.assertEqual([p["code"] for p in self.sent()], out["result"]["codes"])
|
||||
self.assertIsNone(pages["DOC-1"]["parent_code"])
|
||||
self.assertEqual(pages["DOC-3"]["parent_code"], "DOC-1")
|
||||
self.assertEqual(pages["DOC-4"]["parent_code"], "DOC-3")
|
||||
self.assertEqual(pages["DOC-3"]["orderno"], 1)
|
||||
self.assertIsNone(pages["DOC-4"]["orderno"])
|
||||
self.assertEqual(pages["DOC-3"]["url"], self.eva.base_url + "/project/Document/DOC-3")
|
||||
self.assertEqual(pages["DOC-3"]["eva_id"], "3")
|
||||
self.assertEqual(pages["DOC-3"]["title"], "А")
|
||||
self.assertEqual(pages["DOC-3"]["html"], "<p>а</p>")
|
||||
self.assertEqual(self.hub.pages[0]["command_id"], self.cid)
|
||||
|
||||
def test_sync_passes_links_and_attachments(self):
|
||||
self.eva.add_doc(1, "DOC-1", "К", project="P", text=(
|
||||
'<p><a href="/project/Document/DOC-7">x</a><img src="/files/a/b.png" alt="Схема"></p>'))
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
page = self.sent()[0]
|
||||
self.assertEqual(page["links"], [{"ref": "/project/Document/DOC-7", "code": "DOC-7"}])
|
||||
self.assertEqual(page["attachments"], [{"ref": "/files/a/b.png", "url": self.eva.base_url + "/files/a/b.png",
|
||||
"name": "Схема"}])
|
||||
|
||||
def test_sync_batches_50(self):
|
||||
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
for i in range(2, 121):
|
||||
self.eva.add_doc(i, "DOC-%d" % i, "С%03d" % i, parent="DOC-1", project="P", orderno=i)
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual([len(b["pages"]) for b in self.hub.pages], [50, 50, 20])
|
||||
self.assertEqual(len(out["result"]["codes"]), 120)
|
||||
self.assertEqual(out["result"]["codes"][0], "DOC-1")
|
||||
|
||||
def test_sync_batch_bytes(self):
|
||||
big = "<p>" + "x" * (1500 * 1024) + "</p>"
|
||||
self.eva.add_doc(1, "DOC-1", "Корень", project="P", text=big)
|
||||
for i in (2, 3):
|
||||
self.eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i, text=big)
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
sizes = [len(json.dumps(b, ensure_ascii=False).encode("utf-8")) for b in self.hub.pages]
|
||||
self.assertTrue(all(s <= 4 * 1024 * 1024 for s in sizes), sizes)
|
||||
self.assertEqual([len(b["pages"]) for b in self.hub.pages], [2, 1])
|
||||
|
||||
def test_tree_cycle_and_two_parents(self):
|
||||
e = self.eva
|
||||
e.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
e.add_doc(3, "DOC-3", "Три", parent="DOC-1", project="P", orderno=1)
|
||||
e.add_doc(10, "DOC-A", "А", parent="DOC-B", tree_parent="DOC-1", project="P", orderno=2)
|
||||
e.add_doc(11, "DOC-B", "Б", tree_parent="DOC-A", project="P")
|
||||
e.add_doc(12, "DOC-C", "Ц", parent="DOC-1", tree_parent="DOC-3", project="P", orderno=3)
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
codes = out["result"]["codes"]
|
||||
self.assertEqual(len(codes), len(set(codes)))
|
||||
self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-A", "DOC-B", "DOC-C"])
|
||||
self.assertEqual([p["code"] for p in self.sent()], codes)
|
||||
|
||||
def test_root_without_project(self):
|
||||
e = self.eva
|
||||
e.add_doc(1, "DOC-1", "Корень", text="<p>к</p>")
|
||||
e.add_doc(2, "DOC-2", "Б", tree_parent="DOC-1", orderno=2)
|
||||
e.add_doc(3, "DOC-3", "А", tree_parent="DOC-1", orderno=1)
|
||||
e.add_doc(4, "DOC-4", "Внук", tree_parent="DOC-3")
|
||||
e.add_doc(5, "DOC-5", "Посторонний", tree_parent="DOC-9")
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["codes"], ["DOC-1", "DOC-3", "DOC-4", "DOC-2"])
|
||||
self.assertEqual({p["code"]: p["parent_code"] for p in self.sent()},
|
||||
{"DOC-1": None, "DOC-3": "DOC-1", "DOC-4": "DOC-3", "DOC-2": "DOC-1"})
|
||||
|
||||
def test_root_missing(self):
|
||||
out = self.sync("DOC-9")
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertEqual(out["error"], "страница DOC-9 не найдена или нет прав")
|
||||
self.assertEqual(self.hub.pages, [])
|
||||
|
||||
def test_eva_failure_reported(self):
|
||||
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
self.eva.fail_next(503, 4)
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("503", out["error"])
|
||||
self.assertNotIn(FakeEva.token, out["error"])
|
||||
self.assertEqual(self.hub.pages, [])
|
||||
|
||||
def test_hub_rejects_batch(self):
|
||||
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
self.hub.reject_pages = (422, {"error": "bad_pages", "detail": "DOC-1: html"})
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("422", out["error"])
|
||||
self.assertIn("DOC-1: html", out["error"])
|
||||
|
||||
def test_empty_name_falls_back_to_code(self):
|
||||
self.eva.add_doc(1, "DOC-1", "", project="P") # хаб отклоняет пустой title (stage._check)
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(self.sent()[0]["title"], "DOC-1")
|
||||
self.assertEqual(out["result"]["root_title"], "DOC-1")
|
||||
|
||||
def test_page_vanished(self):
|
||||
e = self.eva
|
||||
e.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P")
|
||||
real = self.client.get_doc
|
||||
self.client.get_doc = lambda code, fields: None if code == "DOC-2" else real(code, fields)
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("DOC-2", out["error"])
|
||||
|
||||
def test_counts(self):
|
||||
e = self.eva
|
||||
e.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P")
|
||||
e.fail_next(429, 2, retry_after=0)
|
||||
out = self.sync()
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["jira_calls"], len(e.requests))
|
||||
self.assertEqual(out["jira_calls"], self.client.calls)
|
||||
self.assertEqual(out["rate_limited"], 2)
|
||||
|
||||
|
||||
class WikiSyncRecoverTest(unittest.TestCase):
|
||||
def test_recover_resyncs(self):
|
||||
eva, hub = FakeEva(), FakeHub(SETTINGS)
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
for x in (eva.stop, hub.stop, tmp.cleanup):
|
||||
self.addCleanup(x)
|
||||
eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||
for i in range(2, 61):
|
||||
eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i)
|
||||
cid = hub.enqueue("wiki_sync", {"label": "tz", "root": "DOC-1"})
|
||||
hub.queue.remove(cid)
|
||||
hub.commands[cid]["status"] = "claimed"
|
||||
hub.in_flight = cid
|
||||
journal = agent.Journal(os.path.join(tmp.name, "journal.jsonl"))
|
||||
journal.claimed(cid)
|
||||
journal.started(cid) # перезапуск посреди выполнения: finished нет
|
||||
cfg = eva_cfg(eva.base_url, hub_url=hub.base_url, hub_token=hub.token)
|
||||
runner = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), journal)
|
||||
runner.sleep = lambda s: None
|
||||
runner.eva.sleep = no_sleep
|
||||
runner.idle_limit = 1
|
||||
runner.loop()
|
||||
self.assertEqual(hub.results[cid]["status"], "done", hub.results[cid])
|
||||
self.assertEqual(len(hub.results[cid]["result"]["codes"]), 60)
|
||||
self.assertEqual([len(b["pages"]) for b in hub.pages], [50, 10])
|
||||
self.assertEqual(len([r for r in journal.records() if r["kind"] == "finished"]), 1)
|
||||
self.assertEqual(journal.unfinished_started(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user