Вики: лента правок, версии и дифф в HTTP API (#140)

Маршруты GET /wiki/changes, /wiki/pages/{code}/versions, /wiki/pages/{code}/diff;
у страницы — modified_at, modified_by, versions; у состояния — history_at.
Дифф режется по 20 000 символов, фильтр автора без учёта регистра в Python.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 12:16:04 +04:00
parent a11d66f94e
commit 3a936f5eaa
6 changed files with 345 additions and 8 deletions

View File

@ -65,12 +65,12 @@
| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `deliver_params` дописывает `known_versions`, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` |
| `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` |
| `history` | история правок: время версий в UTC (`utc`), `known_versions` для выдачи, подсчёт «+/−» (`line_delta`), `history_at` | stdlib |
| `history` | история правок: время версий в UTC (`utc`), `known_versions` для выдачи, подсчёт «+/−» (`line_delta`), `history_at`; чтение истории: лента `feed`, версии страницы `page_versions`, дифф по запросу (`diff`, `difflib`, части по 20 000 — `CHUNK`), последняя правка `last_change`; HTTP не знает | stdlib |
| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS; запись версий, «+/−» (подсчёт в `prepare`, вне транзакции), `history_at` | `core.db`, `paths`, `search`, `convert`, `history` |
| `search` | поиск по FTS5 (запасной LIKE по `index.md`, если FTS5 в SQLite нет), фрагменты | `paths` |
| `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` |
| `tick` | фоновая синхронизация проектов с корнем раз в `wiki_auto_sync_hours` | `core.db`, `core.settings`, `commands`, `sync`, `search` |
| `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages`, `POST /agent/wiki/versions` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` |
| `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/pages/{code}/versions`, `/wiki/pages/{code}/diff`, `/wiki/changes` (лента правок), `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages`, `POST /agent/wiki/versions` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` |
| `mcp` | инструменты сервера `sdlc-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`; Markdown и имена вложений недоверенные | `core.mcp`, `core.client` |
### Контракт модуля
@ -131,7 +131,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
Цепочки зависимостей (Python):
- ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`;
- модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`;
- модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`; `api → history`; `sync → history`;
- модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `api → agile → history`; `importer → mirror`;
- веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта).
@ -146,6 +146,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона
(новая отметка) → `GET /changes`, `GET /issues/{key}.changes`, MCP `changes`.
- Синхронизация вики: `wiki_sync {label, root}` ставят MCP `sync`, кнопка «Обновить» (`POST /wiki/sync`), привязка/смена корня (`on_project_created`/`on_project_updated`) и `tick` (раз в `wiki_auto_sync_hours`) → `claim` агента `wiki` → eva-agent обходит дерево и шлёт пачки `POST /agent/wiki/pages` (хаб принимает ≤50 страниц в пачке; срез по 4 МБ делает агент) → `stage.accept` переводит HTML в Markdown вне транзакции и кладёт в `wiki_sync_stage` → после страниц eva-agent v2 шлёт версии `POST /agent/wiki/versions` (≤50 в пачке; хаб при выдаче дописывает `known_versions`), отчёт `done {root_title, codes, versions}`; без `versions` (агент v1) история не меняется → `before_result` (`sync.prepare`: сборка `wiki/.<метка>.new/`, выделение id вложений, под per-label `Lock`) → транзакция отчёта (`on_result` = `sync.commit`: строки `wiki_page`/`wiki_attachment`/FTS, атомарная подмена каталога) → `after_result` снимает подготовку; при сбое — `abort_result`. Отчёт по команде с устаревшим корнем отбрасывается (`result.discarded`), копия прежняя.
- История вики: `GET /wiki/changes` (лента правок за окно `since`/`until`, фильтр `author`), `GET /wiki/pages/{code}/versions`, `GET /wiki/pages/{code}/diff` (дифф по частям, `offset`), MCP `changes`/`diff`, веб «Лента» (только база хаба) → `history.feed`/`page_versions`/`diff` по `wiki_version` и `wiki_page` (только текущая копия); команд не ставит и Eva не вызывает.
- Вложение вики: `POST /wiki/attachments/{id}/fetch` (веб, MCP `attachment`) → `attachments.request_fetch` (одна транзакция: `state = fetching` + пакет `wiki_fetch {attachment_id, url}`) → `claim` → eva-agent качает файл только с хоста Eva → `POST /agent/files` → результат `{file, size}` → хаб проверяет файл в `files/` и ставит `ready` (иначе `failed`) → `GET /wiki/attachments/{id}/content`; в MCP копия `downloads/wiki-<id>/<имя>`.
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
- Самообновление агента: `sdlc-hub agent publish [--module M] PATH` → `release/<модуль>/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.
@ -175,7 +176,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
- Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже.
- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их.
- Запись в вики Eva (правка страниц из хаба) не делается: модуль только читает.
- История страниц вики и частичная перекачка: сейчас каждая синхронизация перекачивает всё дерево, копия без истории.
- Частичная перекачка: каждая синхронизация перекачивает всё дерево страниц (версии — только новые, change `wiki-history`).
- Переименование проекта-метки и перенос корня вики между проектами не предусмотрены (меняется только корень — `PATCH /projects`).
- Ручной просмотр вёрстки веб-страницы «Вики» на живых данных (таблицы, вложенные списки) — за владельцем после выкатки.
- Хаб переименован в sdlc-hub (24.10.2026, change `sdlc-rename`): пакет `sdlc_hub`, команда `sdlc-hub`, каталог данных `~/.local/share/sdlc-hub/` (`SDLC_HUB_HOME`), MCP-серверы `sdlc-jira` и `sdlc-wiki`, launchd `ru.aepif.sdlc-hub`.

View File

@ -237,6 +237,11 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` (общая JQL по меткам всех проектов; проектов нет — 422 «нет проектов») |
| `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` |
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок (`history_at` — когда принят журнал изменений полного прогона) |
| `GET /wiki/changes` | клиент | лента правок вики проекта: `project`, `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `author` (подстрока имени или логина, без учёта регистра), `limit` (1–2000, по умолчанию 500) → `{since, until, synced_at, history_at, truncated, changes}` |
| `GET /wiki/pages/{code}/versions` | клиент | версии страницы, новые первыми: `{versions: [{version, at, author, added, removed}]}`; страницы нет в копии — 404 |
| `GET /wiki/pages/{code}/diff` | клиент | дифф версии (`version`, по умолчанию последняя) относительно предыдущей, по частям `offset` (части по 20 000 символов, `next_offset`); нет версий — 404 `no_versions`, нет такой версии — 404 `no_version` |
| `GET /wiki/status` | клиент | состояние синхронизации вики проекта, в том числе `history_at` (когда загружена история; `null` — ещё не загружалась) |
| `GET /wiki/pages/{code}` | клиент | страница копии, в том числе `modified_at`, `modified_by` (`{login, name}` или `null`) и `versions` — последняя правка и число версий |
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET); `base_jql` вычисляется из проектов (без проектов — `null`), запись `base_jql` и `board_id` — 422 |
```bash

View File

@ -1,10 +1,11 @@
"""HTTP модуля wiki: маршруты клиента (состояние, дерево, страница, поиск, синхронизация, вложения) и агента."""
import json
from datetime import timedelta
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
from ...core.http import ApiError, Raw, int_group, is_digits, not_found, project_param
from . import attachments, commands, history, paths, search, stage
MIME = "application/octet-stream" # типа в базе вики нет; скачивание — всегда вложением, без угадывания
@ -42,7 +43,8 @@ def _batch(conn, label):
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)}
"pages": r["pages_count"], "error": r["sync_error"], "batch": _batch(conn, label),
"history_at": history.history_at(conn, label)}
def _walk_order(conn, label) -> list[str]:
@ -81,7 +83,63 @@ def h_page(hub, conn, m, q, body):
"SELECT id, name, state, size, error, url 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}
"markdown": text, "attachments": atts, **history.last_change(conn, label, code)}
def _time_param(q, name, default):
"""Время запроса: ISO-8601 со смещением или Z → UTC; без смещения — 422 (пояс не угадываем)."""
raw = q.get(name)
if raw in (None, ""):
return default
value = history.utc(raw)
if value is None:
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
return value
def h_changes(hub, conn, m, q, body):
label, r = _project(conn, q)
until = _time_param(q, "until", db.now())
since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat())
if since > until:
raise ApiError(422, "bad_request", "since: позже until")
try:
limit = int(q.get("limit", 500))
except (TypeError, ValueError):
limit = 0
if not 1 <= limit <= 2000:
raise ApiError(422, "bad_request", "limit: целое от 1 до 2000")
out = history.feed(conn, label, since, until, q.get("author") or None, limit)
return 200, {"since": since, "until": until, "synced_at": r["synced_at"], "history_at": history.history_at(conn, label),
"truncated": out["truncated"], "changes": out["changes"]}
def _wiki_page(conn, label, code):
return not_found(conn.execute("SELECT * FROM wiki_page WHERE label = ? AND code = ?", (label, code)).fetchone(),
f"страница {code}")
def h_versions(hub, conn, m, q, body):
label, _ = _project(conn, q)
code = unquote(m.group(1))
_wiki_page(conn, label, code)
return 200, {"versions": history.page_versions(conn, label, code)}
def h_diff(hub, conn, m, q, body):
label, _ = _project(conn, q)
code = unquote(m.group(1))
page_row = _wiki_page(conn, label, code)
raw = q.get("offset", "0")
if not is_digits(raw):
raise ApiError(422, "bad_request", "offset: целое ≥ 0")
try:
out = history.diff(conn, label, code, q.get("version") or None, int(raw))
except history.NoVersions:
raise ApiError(404, "no_versions", f"у страницы {code} нет версий") from None
except history.NoVersion:
raise ApiError(404, "no_version", f"версии {q.get('version')} у страницы {code} нет") from None
return 200, {"code": code, "title": page_row["title"], "path": page_row["path"], **out}
def h_search(hub, conn, m, q, body):
@ -151,6 +209,9 @@ ROUTES = (
("GET", r"^/wiki/status$", h_status, "client"),
("GET", r"^/wiki/tree$", h_tree, "client"),
("GET", r"^/wiki/pages/([^/]+)$", h_page, "client"),
("GET", r"^/wiki/changes$", h_changes, "client"),
("GET", r"^/wiki/pages/([^/]+)/versions$", h_versions, "client"),
("GET", r"^/wiki/pages/([^/]+)/diff$", h_diff, "client"),
("GET", r"^/wiki/search$", h_search, "client"),
("POST", r"^/wiki/sync$", h_sync, "client"),
("GET", r"^/wiki/attachments/(\d+)$", h_attachment_get, "client"),

View File

@ -37,3 +37,82 @@ def history_at(conn, label: str) -> str | None:
"""Время последней загрузки истории проекта или None, если её ещё не было."""
row = conn.execute("SELECT history_at FROM wiki_history WHERE label = ?", (label,)).fetchone()
return row["history_at"] if row else None
# --- чтение истории (spec wiki «Лента правок», «Версии и дифф», wiki-history #140) ---------------------------------
CHUNK = 20000 # символов в части диффа (как у `page`)
class NoVersions(Exception):
"""У страницы нет ни одной версии в копии истории."""
class NoVersion(Exception):
"""Версии с таким кодом у страницы нет."""
def _author(row) -> dict:
return {"login": row["author_login"], "name": row["author_name"]}
def feed(conn, label: str, since: str, until: str, author: str | None, limit: int) -> dict:
"""Лента правок страниц текущей копии в [since, until]: по `at` ↓, затем `version` ↓. Фильтр автора — подстрока
без учёта регистра в имени или логине (casefold в Python: SQLite LOWER/LIKE знают только ASCII); срез — после фильтра.
Возвращает {truncated, changes}: truncated — правок больше limit."""
rows = conn.execute(
"SELECT v.code, v.created_at, v.author_login, v.author_name, v.added, v.removed, v.page_code, p.title, p.path "
"FROM wiki_version v JOIN wiki_page p ON p.label = v.label AND p.code = v.page_code "
"WHERE v.label = ? AND v.created_at >= ? AND v.created_at <= ? "
"ORDER BY v.created_at DESC, v.code DESC", (label, since, until)).fetchall()
needle = author.casefold() if author else None
out = []
for r in rows:
if needle is not None and not any(needle in (x or "").casefold() for x in (r["author_name"], r["author_login"])):
continue
out.append({"at": r["created_at"], "page_code": r["page_code"], "title": r["title"], "path": r["path"],
"version": r["code"], "author": _author(r), "added": r["added"], "removed": r["removed"]})
return {"truncated": len(out) > limit, "changes": out[:limit]}
def page_versions(conn, label: str, code: str) -> list[dict]:
"""Версии страницы, новые первыми."""
rows = conn.execute("SELECT code, created_at, author_login, author_name, added, removed FROM wiki_version "
"WHERE label = ? AND page_code = ? ORDER BY created_at DESC, code DESC", (label, code)).fetchall()
return [{"version": r["code"], "at": r["created_at"], "author": _author(r), "added": r["added"], "removed": r["removed"]}
for r in rows]
def diff(conn, label: str, code: str, version: str | None, offset: int) -> dict:
"""Дифф версии относительно предыдущей (первой — относительно пустого текста); version None — последняя.
Текст диффа режется по CHUNK символов, с offset; next_offset — где продолжить или None."""
rows = conn.execute("SELECT code, created_at, markdown, added, removed FROM wiki_version "
"WHERE label = ? AND page_code = ? ORDER BY created_at, code", (label, code)).fetchall()
if not rows:
raise NoVersions()
if version is None:
idx = len(rows) - 1
else:
idx = next((i for i, r in enumerate(rows) if r["code"] == version), None)
if idx is None:
raise NoVersion()
cur = rows[idx]
prev = rows[idx - 1] if idx > 0 else None
lines = difflib.unified_diff((prev["markdown"] if prev else "").splitlines(), cur["markdown"].splitlines(),
fromfile=prev["code"] if prev else "", tofile=cur["code"], n=3, lineterm="")
text = "".join(f"{x}\n" for x in lines)
end = offset + CHUNK
return {"version": cur["code"], "index": idx + 1, "total": len(rows), "prev_version": prev["code"] if prev else None,
"added": cur["added"], "removed": cur["removed"], "diff": text[offset:end],
"next_offset": end if end < len(text) else None}
def last_change(conn, label: str, code: str) -> dict:
"""Последняя правка страницы: время, автор (None — если автора нет) и число версий."""
n = conn.execute("SELECT COUNT(*) AS n FROM wiki_version WHERE label = ? AND page_code = ?", (label, code)).fetchone()["n"]
if n == 0:
return {"modified_at": None, "modified_by": None, "versions": 0}
r = conn.execute("SELECT created_at, author_login, author_name FROM wiki_version WHERE label = ? AND page_code = ? "
"ORDER BY created_at DESC, code DESC LIMIT 1", (label, code)).fetchone()
by = _author(r) if r["author_login"] is not None or r["author_name"] is not None else None
return {"modified_at": r["created_at"], "modified_by": by, "versions": n}

View File

@ -251,12 +251,30 @@
"client",
"h_attachment_fetch"
],
[
"GET",
"^/wiki/changes$",
"client",
"h_changes"
],
[
"GET",
"^/wiki/pages/([^/]+)$",
"client",
"h_page"
],
[
"GET",
"^/wiki/pages/([^/]+)/diff$",
"client",
"h_diff"
],
[
"GET",
"^/wiki/pages/([^/]+)/versions$",
"client",
"h_versions"
],
[
"GET",
"^/wiki/search$",

View File

@ -0,0 +1,173 @@
"""История правок вики, чтение HTTP API (wiki-history, #140): лента `GET /wiki/changes`, версии, дифф, поля страницы и
состояния. Агента имитирует `run_history` из test_wiki_history; фикстуры — через api из test_wiki_sync."""
import difflib
from datetime import timedelta
import pytest
from sdlc_hub.core import db
from sdlc_hub.core.client import HubError
from test_wiki_history import MOLCH, SOSNOV, run_history, version # noqa: F401
from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура
DAY_SINCE, DAY_UNTIL = "2026-10-27T00:00:00+03:00", "2026-10-28T00:00:00+03:00"
def window(**extra):
return {"project": "AWPCA", "since": DAY_SINCE, "until": DAY_UNTIL} | extra
def hub_error(fn, *a, **kw):
with pytest.raises(HubError) as e:
fn(*a, **kw)
return e.value
@pytest.fixture
def history(api):
"""Версии: DOC-3 (Соснов) 17:59 и 18:02 +03:00, DOC-2 (Молчанов) 26.10 12:00 +03:00; DOC-1 без версий."""
_, done = run_history(api, TREE, [
version("DOC-3", "DOCH-30", "2026-10-27T17:59:00+03:00", "<p>статус</p>"),
version("DOC-3", "DOCH-31", "2026-10-27T18:02:00+03:00", "<p>статус</p><p>новый</p>"),
version("DOC-2", "DOCH-20", "2026-10-26T12:00:00+03:00", "<p>роли</p>", author=MOLCH),
], CODES)
assert done["status"] == "done"
return api
@pytest.fixture
def four(api):
"""Четыре версии DOC-3 Соснова за 27.10 (последняя — 23:48:02 +03:00)."""
stamps = ["2026-10-27T17:59:00+03:00", "2026-10-27T18:02:00+03:00", "2026-10-27T20:30:00+03:00",
"2026-10-27T23:48:02+03:00"]
vs = [version("DOC-3", f"DOCH-4{i}", ts, f"<p>строка {i}</p>") for i, ts in enumerate(stamps, 1)]
_, done = run_history(api, TREE, vs, CODES)
assert done["status"] == "done"
return api
# --- лента -----------------------------------------------------------------------------------------------------------
def test_changes_today(history): # сценарий «Что сделали за сегодня»
d = history.client.get("/wiki/changes", **window())
assert [c["at"] for c in d["changes"]] == ["2026-10-27T15:02:00+00:00", "2026-10-27T14:59:00+00:00"]
c = d["changes"][0]
assert c["page_code"] == "DOC-3" and c["title"] == "Статусы" and c["path"] == "АРМ КА/01_Статусы"
assert c["author"] == SOSNOV and c["version"] == "DOCH-31"
assert isinstance(c["added"], int) and isinstance(c["removed"], int)
assert d["since"] == "2026-10-26T21:00:00+00:00" and d["until"] == "2026-10-27T21:00:00+00:00"
assert d["truncated"] is False and d["synced_at"] and d["history_at"]
@pytest.mark.parametrize("author", ["соснов", "СОСНОВ", "Sosnov-DVI"])
def test_changes_author_cyrillic_case(history, author): # Review Focus 1: только две правки DOC-3
d = history.client.get("/wiki/changes", **window(author=author))
assert len(d["changes"]) == 2 and {c["page_code"] for c in d["changes"]} == {"DOC-3"}
def test_changes_default_window(history): # без since/until: окно ровно 24 часа
d = history.client.get("/wiki/changes", project="AWPCA")
assert db.parse(d["until"]) - db.parse(d["since"]) == timedelta(hours=24)
def test_changes_limit_truncated(history):
d = history.client.get("/wiki/changes", **window(limit="1"))
assert len(d["changes"]) == 1 and d["truncated"] is True
@pytest.mark.parametrize("q,param", [
({"since": "2026-10-27T00:00:00"}, "since"), ({"until": "вчера"}, "until"),
({"limit": "0"}, "limit"), ({"limit": "2001"}, "limit"), ({"limit": "x"}, "limit"),
({"since": "2026-10-28T00:00:00Z", "until": "2026-10-27T00:00:00Z"}, "since"),
])
def test_changes_bad_params(history, q, param):
e = hub_error(history.client.get, "/wiki/changes", **({"project": "AWPCA"} | q))
assert e.status == 422 and e.error == "bad_request" and e.detail.startswith(param)
def test_changes_history_not_loaded(api): # синхронизация без версий: история не загружена
first_sync(api)
d = api.client.get("/wiki/changes", **window())
assert d["history_at"] is None and d["changes"] == [] and d["synced_at"]
assert api.client.get("/wiki/status", project="AWPCA")["history_at"] is None
def test_changes_no_root_and_unknown(api):
api.client.post("/projects", {"label": "BEZ", "name": "Без корня"})
assert hub_error(api.client.get, "/wiki/changes", project="BEZ").status == 409
assert hub_error(api.client.get, "/wiki/changes", project="NOPE").status == 404
# --- версии страницы -------------------------------------------------------------------------------------------------
def test_versions_list(history): # новые первыми
d = history.client.get("/wiki/pages/DOC-3/versions", project="AWPCA")
assert len(d["versions"]) == 2 and d["versions"][0]["at"] == "2026-10-27T15:02:00+00:00"
assert d["versions"][0]["version"] == "DOCH-31" and d["versions"][0]["author"] == SOSNOV
assert hub_error(history.client.get, "/wiki/pages/DOC-99/versions", project="AWPCA").status == 404
# --- дифф ------------------------------------------------------------------------------------------------------------
def test_diff_last(four): # сценарий «Дифф последней правки»
d = four.client.get("/wiki/pages/DOC-3/diff", project="AWPCA")
v3, v4 = "DOCH-43", "DOCH-44"
assert d["index"] == 4 and d["total"] == 4 and d["prev_version"] == v3 and d["version"] == v4
assert d["diff"].startswith(f"--- {v3}\n+++ {v4}\n") and d["next_offset"] is None
assert d["code"] == "DOC-3" and d["title"] == "Статусы" and d["path"] == "АРМ КА/01_Статусы"
def test_diff_first_version(history):
d = history.client.get("/wiki/pages/DOC-3/diff", project="AWPCA", version="DOCH-30")
assert d["index"] == 1 and d["prev_version"] is None and d["removed"] == 0
assert d["diff"].startswith("--- \n+++ ")
def test_diff_chunks(api): # сценарий «Длинный дифф»: склейка частей == полный дифф
html = "".join(f"<p>строка {i:04d} текста, которой много</p>" for i in range(1300))
_, done = run_history(api, TREE, [version("DOC-3", "DOCH-90", "2026-10-27T10:00:00Z", html)], CODES)
assert done["status"] == "done"
md = api.rows("SELECT markdown FROM wiki_version WHERE code = 'DOCH-90'")[0]["markdown"]
full = "".join(f"{x}\n" for x in difflib.unified_diff([], md.splitlines(), fromfile="", tofile="DOCH-90",
n=3, lineterm=""))
assert 40000 < len(full) <= 60000
def part(offset):
return api.client.get("/wiki/pages/DOC-3/diff", project="AWPCA", offset=str(offset))
p0 = part(0)
assert len(p0["diff"]) == 20000 and p0["next_offset"] == 20000 and p0["diff"] == full[:20000]
p40 = part(40000)
assert p40["diff"] == full[40000:] and p40["next_offset"] is None
assert p0["diff"] + part(20000)["diff"] + p40["diff"] == full
for bad in ("-1", "x"):
e = hub_error(api.client.get, "/wiki/pages/DOC-3/diff", project="AWPCA", offset=bad)
assert e.status == 422 and e.error == "bad_request" and e.detail == "offset: целое ≥ 0"
def test_diff_errors(history):
e = hub_error(history.client.get, "/wiki/pages/DOC-99/diff", project="AWPCA")
assert e.status == 404 and e.error == "not_found"
e = hub_error(history.client.get, "/wiki/pages/DOC-3/diff", project="AWPCA", version="DOCH-404")
assert e.status == 404 and e.error == "no_version"
e = hub_error(history.client.get, "/wiki/pages/DOC-1/diff", project="AWPCA")
assert e.status == 404 and e.error == "no_versions"
# --- поля страницы и состояния ---------------------------------------------------------------------------------------
def test_page_last_change(four): # сценарий «Последняя правка в странице»
p = four.client.get("/wiki/pages/DOC-3", project="AWPCA")
assert p["modified_at"] == "2026-10-27T20:48:02+00:00"
assert p["modified_by"] == SOSNOV and p["versions"] == 4
def test_page_last_change_without_versions(history):
p = history.client.get("/wiki/pages/DOC-1", project="AWPCA")
assert p["modified_at"] is None and p["modified_by"] is None and p["versions"] == 0
def test_status_history_at(history):
st = history.client.get("/wiki/status", project="AWPCA")
stored = history.rows("SELECT history_at FROM wiki_history")[0]["history_at"]
assert stored and st["history_at"] == stored