Вики: таблицы версий и приём POST /agent/wiki/versions (#140)

- schema: wiki_version, wiki_version_stage, wiki_history (без миграций, IF NOT EXISTS)
- history.utc: время версии в UTC; без смещения или не строка — None
- convert.to_markdown(file_links=False): вложение текстом «📎 имя», список файлов пуст
- stage.accept_versions + POST /agent/wiki/versions (agent wiki): проверка формы, перевод вне транзакции
- эталоны baseline (routes, schema) и документы: design.md (П-1…П-12), ARCHITECTURE.md

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 11:57:37 +04:00
parent de3c04701e
commit 7b18ae53ad
11 changed files with 344 additions and 16 deletions

View File

@ -58,13 +58,13 @@
| Модуль | Ответственность | Зависит от |
|---|---|---|
| `schema` | таблицы модуля: `wiki_project`, `wiki_page`, `wiki_attachment`, `wiki_sync_stage`; таблица FTS5 `wiki_page_fts` создаётся лениво при первой синхронизации (`search.ensure`), в `schema` её нет | stdlib |
| `schema` | таблицы модуля: `wiki_project`, `wiki_page`, `wiki_attachment`, `wiki_sync_stage`, `wiki_version`, `wiki_version_stage`, `wiki_history`; таблица FTS5 `wiki_page_fts` создаётся лениво при первой синхронизации (`search.ensure`), в `schema` её нет | stdlib |
| `settings` | умолчания (`wiki_pacing`, `wiki_auto_sync_hours`, `wiki_min_agent_version`), ключи для агента, проверка значений | `core.settings` |
| `paths` | каталог копий `<каталог базы>/wiki/<метка>/` | `core.db` |
| `projects` | поле проекта `wiki_root` (изменяемое), `project_view` (`wiki_root`, `pages_count`, `synced_at`…), `on_project_created`/`on_project_updated` (постановка `wiki_sync`, отмена `queued`, сброс копии при смене корня) | `core.projects`, `core.queue`, `commands`, `paths` |
| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` |
| `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:<id>` и `page:<код>`; HTML считается недоверенным | stdlib, `markdownify` |
| `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage` | `core.db`, `convert` |
| `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:<id>` и `page:<код>`; режим без ссылок на вложения для версий (`file_links=False`); HTML считается недоверенным | stdlib, `markdownify` |
| `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage`; и пачек версий (`accept_versions`, `POST /agent/wiki/versions`), стадия `wiki_version_stage` | `core.db`, `convert` |
| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS | `core.db`, `paths`, `search`, `convert` |
| `search` | поиск по FTS5 (запасной LIKE по `index.md`, если FTS5 в SQLite нет), фрагменты | `paths` |
| `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` |

View File

@ -151,3 +151,35 @@
(`replace`), чтобы «назад» закрывал окно. Цена ошибки: ссылка на правку открывается не поверх ленты.
- 28.10.2026 — **фильтр по автору** в `GET /wiki/changes` и MCP `changes` — подстрока имени или логина без учёта
регистра. Цена ошибки: «Иван» находит и «Иванову».
- 28.10.2026 — **П-1: `history_at` — своя таблица `wiki_history(label, history_at)` без миграции.** Обоснование:
схема `wiki_project` не меняется, миграция не нужна. Цена ошибки: ещё одна таблица на проект.
- 28.10.2026 — **П-2: повтор версии.** В хабе версия с уже известным кодом не перезаписывается (`INSERT OR IGNORE`,
решение 1 — опубликованная версия неизменна); в стадии повтор кода заменяет строку (`INSERT OR REPLACE`), N в
«пришло N из M» — число уникальных кодов в стадии. Цена ошибки: исправленная Eva версия не попадёт в историю.
- 28.10.2026 — **П-3: `result.versions` некорректен.** Не целое ≥ 0 (строка, `true`, −1) — команда `failed`
«versions — неотрицательное целое»; присланных больше `M` — применяются все. Цена ошибки: агент с багом в счётчике
уронит команду, а не только историю.
- 28.10.2026 — **П-4: «+/−» считаются в `sync.prepare`** (вне транзакции), пишутся в `sync._commit`; число — строки
`+`/`-` из `difflib.unified_diff` (те же, что в диффе правки). Цена ошибки: при смене алгоритма диффа старые «+/−»
расходятся с новым диффом.
- 28.10.2026 — **П-5: время версии.** Дробные секунды отбрасываются; равное время — порядок по коду строкой (решение
спеки «затем по коду»). Цена ошибки: две правки в одну секунду идут в ленте по коду, а не по порядку правки.
- 28.10.2026 — **П-6: фильтр автора в Python (`casefold`)** до среза `limit`. Цена ошибки: на больших лентах фильтр
медленнее, чем в SQL.
- 28.10.2026 — **П-7: `GET …/diff`.** `offset` не целое ≥ 0 — 422 `bad_request` «offset: целое ≥ 0»; `offset` за концом —
`diff = ""`, `next_offset = null`. Цена ошибки: клиент, ждавший 404, получит пустой дифф.
- 28.10.2026 — **П-8: MCP.** Шапка `diff`; строка `status` «история: <history_at>» / «история не загружена»; `sync` при
отменённом пакете — «ошибка: пакет N отменён»; время в «ок: синхронизировано …» — `synced_at` из `GET /wiki/status`;
автор правки — имя, иначе логин, иначе «—» (и в вебе). Цена ошибки: формулировки MCP могут не совпасть с ожиданием
владельца; смысл не меняется.
- 28.10.2026 — **П-9: помощники времени MCP (`local_if_naive`, `since_of`) — в `core/mcp.py`**, сервер jira берёт их
оттуда. Цена ошибки: ядро получает функции форматирования времени, общие для двух модулей.
- 28.10.2026 — **П-10: веб — в шапке «Ленты» вторичные кнопки обновления модулей выбранных источников** («Обновить из
Jira», «Обновить вики»). Цена ошибки: лишняя кнопка на одном из источников, если модуль не подключён.
- 28.10.2026 — **П-11: веб — тексты сообщений.** `truncated` и ошибки источника — с префиксом «<Источник>: »;
«выберите хотя бы один вид» и «история вики ещё не загружена…» — без префикса; «за период изменений нет» — когда ни
один выбранный источник не грузится и не в ошибке, хотя бы один загружен без своего сообщения и групп нет;
`sources=none` — явный пустой набор (как `kinds=none`). Цена ошибки: формулировки сообщений, не смысл.
- 28.10.2026 — **П-12: веб — `eventTime` переезжает в `ui/format.ts`, период (`PERIODS`, `parsePeriod`, `periodRange`)
— в `shell/feed/period.ts`;** прежнее перенаправление `/app/feed` → `/app/jira/feed` снимается (адрес теперь страница
ядра). Цена ошибки: закладки на прежний адрес ленты перестают перенаправлять.

View File

@ -13,6 +13,10 @@ def h_agent_pages(hub, conn, m, q, body):
return 200, stage.accept(conn, body.get("command_id"), body.get("pages"))
def h_agent_versions(hub, conn, m, q, body):
return 200, stage.accept_versions(conn, body.get("command_id"), body.get("versions"))
def _project(conn, q) -> tuple[str, dict]:
"""(метка, строка wiki_project): без project — 422, неизвестный — 404, без корня — 409 no_root."""
label = project_param(conn, q)
@ -143,6 +147,7 @@ def h_attachment_content(hub, conn, m, q, body):
ROUTES = (
("POST", r"^/agent/wiki/pages$", h_agent_pages, "agent"),
("POST", r"^/agent/wiki/versions$", h_agent_versions, "agent"),
("GET", r"^/wiki/status$", h_status, "client"),
("GET", r"^/wiki/tree$", h_tree, "client"),
("GET", r"^/wiki/pages/([^/]+)$", h_page, "client"),

View File

@ -146,8 +146,15 @@ def _strip_editor_ui(soup) -> None:
el.decompose()
def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None) -> tuple[str, list[dict]]:
"""HTML страницы → (Markdown, вложения страницы [{url, name}])."""
_FILE_LINK = re.compile(r"\[📎 ((?:\\.|[^\]\\])*)\]\(attachment:@\d+\)")
def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None, *,
file_links: bool = True) -> tuple[str, list[dict]]:
"""HTML страницы → (Markdown, вложения страницы [{url, name}]).
`file_links=False` (версии вики, #140): ссылка вложения становится текстом `📎 <имя>` без адреса, список файлов пуст;
ссылки на страницы остаются `page:<код>`."""
soup = BeautifulSoup(html, "html.parser")
_strip_editor_ui(soup)
_strip_private(soup)
@ -155,6 +162,8 @@ def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | N
md = conv.convert_soup(soup).strip() + "\n"
md = md.replace("attachment:@", "attachment:‌@") # авторское «attachment:@» больше не совпадает с шаблоном
md = _MARK.sub(lambda m: "](attachment:@%s)" % m.group(1), md) # настоящие ссылки вложений
if not file_links:
return _FILE_LINK.sub(lambda m: "📎 " + m.group(1), md), []
return md, conv.files

View File

@ -0,0 +1,16 @@
"""История правок вики (wiki-history, #140): время версий в UTC для приёма и для хранения."""
from datetime import datetime, timezone
def utc(value) -> str | None:
"""ISO-8601 со смещением или `Z` (дробные секунды допустимы) → UTC в формате `db.now()`; иначе `None`:
без смещения, не строка или неразбираемое значение."""
if not isinstance(value, str):
return None
try:
dt = datetime.fromisoformat(value)
except ValueError:
return None
if dt.tzinfo is None:
return None
return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat()

View File

@ -43,4 +43,24 @@ CREATE TABLE IF NOT EXISTS wiki_sync_stage (
payload TEXT NOT NULL,
PRIMARY KEY (command_id, code)
);
CREATE TABLE IF NOT EXISTS wiki_version (
label TEXT NOT NULL,
code TEXT NOT NULL,
page_code TEXT NOT NULL,
created_at TEXT NOT NULL,
author_login TEXT,
author_name TEXT,
markdown TEXT NOT NULL,
added INTEGER NOT NULL DEFAULT 0,
removed INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (label, code));
CREATE INDEX IF NOT EXISTS wiki_version_page ON wiki_version(label, page_code, created_at);
CREATE INDEX IF NOT EXISTS wiki_version_time ON wiki_version(label, created_at);
CREATE TABLE IF NOT EXISTS wiki_version_stage (
command_id INTEGER NOT NULL,
code TEXT NOT NULL,
page_code TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (command_id, code));
CREATE TABLE IF NOT EXISTS wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL);
"""

View File

@ -3,10 +3,12 @@ import json
from ...core import db, queue
from ...core.http import ApiError
from . import convert
from . import convert, history
MAX_PAGES = 50
MAX_STAGE = 20000 # страниц одной команды в стадии (между пачками)
MAX_VERSIONS = 50 # версий в одной пачке (#140)
MAX_VERSION_STAGE = 20000 # версий одной команды в стадии (между пачками)
LIMITS = {"code": 200, "title": 1000, "url": 4000, "parent_code": 200, "eva_id": 200}
@ -35,6 +37,20 @@ def _clean(v):
return v
def _refs(attachments, links, fail) -> tuple[list, list]:
"""Вложения и ссылки страницы или версии → поля для стадии; `fail(поле)` строит ошибку формы."""
attachments, links = attachments or [], links or []
if not isinstance(attachments, 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 attachments):
raise fail("attachments")
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):
raise fail("links")
return ([{"ref": a["ref"], "url": a["url"], "name": a.get("name") or ""} for a in attachments],
[{"ref": l["ref"], "code": l["code"]} for l in links])
def _check(i, page) -> dict:
"""Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`."""
if not isinstance(page, dict):
@ -53,18 +69,10 @@ def _check(i, page) -> dict:
eva_id = page.get("eva_id")
if eva_id is not None and (not isinstance(eva_id, str) or len(eva_id) > LIMITS["eva_id"]):
raise _bad(code, "eva_id")
attachments, links = page.get("attachments") or [], page.get("links") or []
if not isinstance(attachments, 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 attachments):
raise _bad(code, "attachments")
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):
raise _bad(code, "links")
attachments, links = _refs(page.get("attachments"), page.get("links"), lambda f: _bad(code, f))
return {"code": code, "eva_id": eva_id, "title": title, "parent_code": _text(page, "parent_code", code, False, True),
"orderno": orderno, "url": _text(page, "url", code, False, True), "html": html,
"attachments": [{"ref": a["ref"], "url": a["url"], "name": a.get("name") or ""} for a in attachments],
"links": [{"ref": l["ref"], "code": l["code"]} for l in links]}
"attachments": attachments, "links": links}
def accept(conn, command_id, pages) -> dict:
@ -97,3 +105,69 @@ def accept(conn, command_id, pages) -> dict:
raise ApiError(422, "bad_pages", f"pages: в команде больше {MAX_STAGE} страниц")
conn.executemany("INSERT OR REPLACE INTO wiki_sync_stage(command_id, code, payload) VALUES (?, ?, ?)", rows)
return {"accepted": len(rows)}
def _vbad(prefix, field):
return ApiError(422, "bad_versions", f"{prefix}: {field}")
def _version(i, v) -> dict:
"""Форма одной версии → поля для стадии (без markdown); ApiError 422 `bad_versions`: `версия <i>: …` до кода,
`<код>: <поле>` после (#140)."""
if not isinstance(v, dict):
raise ApiError(422, "bad_versions", f"версия {i}: не объект")
v = _clean(v)
code = v.get("code")
if not isinstance(code, str) or not code.strip() or len(code) > LIMITS["code"]:
raise ApiError(422, "bad_versions", f"версия {i}: code")
page_code = v.get("page_code")
if not isinstance(page_code, str) or not page_code.strip() or len(page_code) > LIMITS["code"]:
raise _vbad(code, "page_code")
created_at = history.utc(v.get("created_at"))
if created_at is None:
raise _vbad(code, "created_at")
author = v.get("author")
if not isinstance(author, dict):
raise _vbad(code, "author")
login, name = author.get("login"), author.get("name")
if not all(x is None or isinstance(x, str) for x in (login, name)):
raise _vbad(code, "author")
html = v.get("html")
if not isinstance(html, str):
raise _vbad(code, "html")
attachments, links = _refs(v.get("attachments"), v.get("links"), lambda f: _vbad(code, f))
return {"code": code, "page_code": page_code, "created_at": created_at, "author_login": login,
"author_name": name, "html": html, "attachments": attachments, "links": links}
def accept_versions(conn, command_id, versions) -> dict:
"""Пачка версий команды wiki_sync → стадия версий (#140). Перевод HTML идёт до записи, строки — короткой транзакцией."""
if isinstance(command_id, bool) or not isinstance(command_id, int):
raise ApiError(422, "bad_request", "command_id — целое")
if not isinstance(versions, list) or not versions or len(versions) > MAX_VERSIONS:
raise ApiError(422, "bad_versions", f"versions — список от 1 до {MAX_VERSIONS} версий")
cmd = queue.get_command(conn, command_id)
if cmd is None:
raise ApiError(404, "not_found", f"command {command_id}")
if cmd["type"] != "wiki_sync" or cmd["status"] != "claimed":
raise ApiError(409, "not_claimed", f"команда {command_id} не выдана агенту как wiki_sync")
checked = [_version(i, v) for i, v in enumerate(versions, 1)]
rows = []
for v in checked:
try:
md, _ = convert.to_markdown(v["html"], v["attachments"], v["links"], file_links=False)
except Exception as e: # noqa: BLE001 недоверенный HTML: сбой разбора — ошибка версии, не сервера
raise _vbad(v["code"], f"html ({type(e).__name__})") from None
payload = {k: v[k] for k in ("page_code", "code", "created_at", "author_login", "author_name")}
payload["markdown"] = md
rows.append((command_id, v["code"], v["page_code"], json.dumps(payload, ensure_ascii=False)))
with db.transaction(conn):
cur = conn.execute("SELECT status FROM command WHERE id = ?", (command_id,)).fetchone()
if cur is None or cur["status"] != "claimed": # состояние могло смениться, пока шёл перевод
raise ApiError(409, "not_claimed", f"команда {command_id} уже не в работе")
have = conn.execute("SELECT COUNT(*) AS n FROM wiki_version_stage WHERE command_id = ?", (command_id,)).fetchone()["n"]
if have + len(rows) > MAX_VERSION_STAGE:
raise ApiError(422, "bad_versions", f"versions: в команде больше {MAX_VERSION_STAGE} версий")
conn.executemany("INSERT OR REPLACE INTO wiki_version_stage(command_id, code, page_code, payload) "
"VALUES (?, ?, ?, ?)", rows)
return {"accepted": len(rows)}

View File

@ -77,6 +77,12 @@
"agent",
"h_agent_pages"
],
[
"POST",
"^/agent/wiki/versions$",
"agent",
"h_agent_versions"
],
[
"GET",
"^/agents$",

View File

@ -35,6 +35,16 @@
"ix_jira_sprint_board",
"CREATE INDEX ix_jira_sprint_board ON jira_sprint(board_id)"
],
[
"index",
"wiki_version_page",
"CREATE INDEX wiki_version_page ON wiki_version(label, page_code, created_at)"
],
[
"index",
"wiki_version_time",
"CREATE INDEX wiki_version_time ON wiki_version(label, created_at)"
],
[
"table",
"agent_status",
@ -125,6 +135,11 @@
"wiki_attachment",
"CREATE TABLE wiki_attachment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n label TEXT NOT NULL,\n page_code TEXT NOT NULL,\n url TEXT NOT NULL,\n name TEXT,\n state TEXT NOT NULL DEFAULT 'none',\n file TEXT,\n size INTEGER,\n error TEXT,\n fetched_at TEXT,\n UNIQUE (label, page_code, url)\n)"
],
[
"table",
"wiki_history",
"CREATE TABLE wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL)"
],
[
"table",
"wiki_page",
@ -139,6 +154,16 @@
"table",
"wiki_sync_stage",
"CREATE TABLE wiki_sync_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code)\n)"
],
[
"table",
"wiki_version",
"CREATE TABLE wiki_version (\n label TEXT NOT NULL,\n code TEXT NOT NULL,\n page_code TEXT NOT NULL,\n created_at TEXT NOT NULL,\n author_login TEXT,\n author_name TEXT,\n markdown TEXT NOT NULL,\n added INTEGER NOT NULL DEFAULT 0,\n removed INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (label, code))"
],
[
"table",
"wiki_version_stage",
"CREATE TABLE wiki_version_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n page_code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code))"
]
],
"schema_version": 9

View File

@ -347,3 +347,13 @@ def test_empty_external_link_dropped():
"""Финальное ревью M-1: пустая внешняя ссылка без вложения внутри не выводится адресом."""
md, _ = convert.to_markdown('<p>a<a href="#top"></a>b<a href="http://x"><img src="static/i.png"></a>c</p>', [], [])
assert md == "abc\n"
def test_version_markdown_attachment_as_text():
md, files = convert.to_markdown(_html("page_file.html"), [THUMB, FILE], [], file_links=False)
assert "📎 model.xlsx" in md and "attachment:" not in md and files == []
def test_version_markdown_page_links_kept():
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK], file_links=False)
assert md == "[т](page:DOC-1)\n"

131
tests/test_wiki_history.py Normal file
View File

@ -0,0 +1,131 @@
"""История правок вики (wiki-history, #140): приём версий `POST /agent/wiki/versions`, время `history.utc`."""
import json
import pytest
from sdlc_hub.core.client import HubError
from sdlc_hub.modules.wiki import history
from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура
SOSNOV = {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}
MOLCH = {"login": "molchanov-iv@rshbeco.ru", "name": "Илья Молчанов"}
def version(page_code, code, created_at, html="<p>текст</p>", author=SOSNOV, attachments=None, links=None):
return {"page_code": page_code, "code": code, "created_at": created_at, "author": author, "html": html,
"attachments": attachments or [], "links": links or []}
def stage_rows(api, cmd_id):
return api.rows("SELECT code, page_code, payload FROM wiki_version_stage WHERE command_id = ? ORDER BY code", cmd_id)
def test_versions_staged(api): # перевод и форма payload
cmd = api.claim()
out = api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [
version("DOC-3", "DOCH-1", "2026-10-27T23:48:02+03:00", "<p>статус <b>новый</b></p>")]})
assert out == {"accepted": 1}
(row,) = stage_rows(api, cmd["id"])
p = json.loads(row["payload"])
assert p == {"page_code": "DOC-3", "code": "DOCH-1", "created_at": "2026-10-27T20:48:02+00:00",
"author_login": "sosnov-dvi@rshbeco.ru", "author_name": "Дмитрий Соснов", "markdown": "статус **новый**\n"}
def test_versions_resend_same_batch(api): # Review Focus 2: повтор пачки — одна строка на код
cmd = api.claim()
batch = [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z"), version("DOC-3", "DOCH-2", "2026-10-27T11:00:00Z")]
for _ in range(2):
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch})
assert [r["code"] for r in stage_rows(api, cmd["id"])] == ["DOCH-1", "DOCH-2"]
@pytest.mark.parametrize("raw,stored", [("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"),
("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"),
("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00")])
def test_versions_created_at_forms(api, raw, stored): # Review Focus 4 → created_at в payload == stored
cmd = api.claim()
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-3", "DOCH-1", raw)]})
(row,) = stage_rows(api, cmd["id"])
assert json.loads(row["payload"])["created_at"] == stored
def test_versions_created_at_without_offset_422(api):
cmd = api.claim()
with pytest.raises(HubError) as e:
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [
version("DOC-3", "DOCH-1", "2026-10-27T23:48:02")]})
assert e.value.status == 422 and e.value.error == "bad_versions" and e.value.detail == "DOCH-1: created_at"
def test_versions_author_nulls(api):
cmd = api.claim()
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [
version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z", author={"login": None, "name": None})]})
(row,) = stage_rows(api, cmd["id"])
p = json.loads(row["payload"])
assert p["author_login"] is None and p["author_name"] is None
@pytest.mark.parametrize("field,value", [("page_code", ""), ("page_code", 5), ("html", None), ("author", "Соснов"),
("author", {"login": 1, "name": "x"}), ("attachments", [{"ref": 1}]),
("links", "x")])
def test_versions_bad_field(api, field, value): # 422 bad_versions, detail == f"DOCH-1: {field}"
cmd = api.claim()
bad = version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z") | {field: value}
with pytest.raises(HubError) as e:
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [bad]})
assert e.value.status == 422 and e.value.error == "bad_versions" and e.value.detail == f"DOCH-1: {field}"
assert api.rows("SELECT COUNT(*) AS n FROM wiki_version_stage")[0]["n"] == 0
def test_versions_bad_batch(api): # [] и 51 версия → 422 bad_versions; command_id "1" → 422 bad_request;
# версия-не объект → detail "версия 1: не объект"; без code → "версия 1: code"
cmd = api.claim()
with pytest.raises(HubError) as e:
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": []})
assert e.value.status == 422 and e.value.error == "bad_versions"
many = [version("DOC-3", f"DOCH-{i}", "2026-10-27T10:00:00Z") for i in range(51)]
with pytest.raises(HubError) as e:
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": many})
assert e.value.status == 422 and e.value.error == "bad_versions"
with pytest.raises(HubError) as e:
api.wiki.post("/agent/wiki/versions", {"command_id": "1", "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]})
assert e.value.status == 422 and e.value.error == "bad_request"
with pytest.raises(HubError) as e:
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": ["x"]})
assert e.value.status == 422 and e.value.detail == "версия 1: не объект"
no_code = {k: v for k, v in version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z").items() if k != "code"}
with pytest.raises(HubError) as e:
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [no_code]})
assert e.value.status == 422 and e.value.detail == "версия 1: code"
def test_versions_not_claimed(api): # команда queued → 409 not_claimed; несуществующая → 404
(row,) = api.rows("SELECT id FROM command WHERE type = 'wiki_sync'")
with pytest.raises(HubError) as e:
api.wiki.post("/agent/wiki/versions", {"command_id": row["id"], "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]})
assert e.value.status == 409 and e.value.error == "not_claimed"
with pytest.raises(HubError) as e:
api.wiki.post("/agent/wiki/versions", {"command_id": 999999, "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]})
assert e.value.status == 404
def test_versions_wrong_agent(api): # токеном агента jira (api.jira) → 401
cmd = api.claim()
with pytest.raises(HubError) as e:
api.jira.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]})
assert e.value.status == 401
@pytest.mark.parametrize("raw,expected", [
("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"),
("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"),
("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00"),
("2026-10-27T23:48:02", None), # без смещения
("не дата", None),
(None, None),
(1234, None),
])
def test_history_utc(raw, expected):
assert history.utc(raw) == expected