"""История правок вики, чтение 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", "
статус
"), version("DOC-3", "DOCH-31", "2026-10-27T18:02:00+03:00", "статус
новый
"), version("DOC-2", "DOCH-20", "2026-10-26T12:00:00+03:00", "роли
", 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"строка {i}
") 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 d["code"] == "DOC-3" and d["title"] == "Статусы" 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_Статусы" assert d["at"] == "2026-10-27T20:48:02+00:00" and d["author"] == SOSNOV and d["offset"] == 0 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"строка {i:04d} текста, которой много
" 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] assert p0["offset"] == 0 p40 = part(40000) assert p40["diff"] == full[40000:] and p40["next_offset"] is None and p40["offset"] == 40000 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