"""История правок вики (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="

текст

", 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", "

статус новый

")]}) 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 # --- Task 2: known_versions и запись версий в синхронизации --------------------------------------------------- import sqlite3 # noqa: E402 from sdlc_hub.modules.wiki import convert, stage, sync # noqa: E402 def run_history(api, pages, versions, codes=None, reported=None): """Взять wiki_sync, прислать страницы и версии (пачками по 50), отчитаться done с versions=reported (None — len).""" cmd = api.claim() api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": pages}) for i in range(0, len(versions), 50): api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": versions[i:i + 50]}) result = {"root_title": "АРМ КА", "codes": codes or [p["code"] for p in pages], "eva_calls": 3, "versions": len(versions) if reported is None else reported} return cmd, api.report(cmd, "done", result) def versions_of(api): return api.rows("SELECT code, page_code, added, removed, created_at, markdown FROM wiki_version " "ORDER BY page_code, created_at, code") def first_load(api): vs = ([version("DOC-1", f"DOCH-1{i}", f"2026-10-2{i}T10:00:00Z", "

а

" * (i + 1)) for i in range(3)] + [version("DOC-3", f"DOCH-3{i}", f"2026-10-2{i}T10:00:00Z") for i in range(2)]) return run_history(api, TREE, vs, CODES) def stage_count(api): return api.rows("SELECT COUNT(*) AS n FROM wiki_version_stage")[0]["n"] def test_first_history_load(api): cmd, done = first_load(api) assert cmd["params"]["known_versions"] == {} and done["status"] == "done" rows = versions_of(api) assert len(rows) == 5 assert [(r["added"], r["removed"]) for r in rows if r["code"] in ("DOCH-10", "DOCH-30")] == [(1, 0), (1, 0)] assert api.rows("SELECT history_at FROM wiki_history")[0]["history_at"] assert "known_versions" not in api.rows("SELECT params FROM command WHERE id = ?", cmd["id"])[0]["params"] def test_known_versions_on_delivery_and_recovery(api): first_load(api) api.enqueue() cmd = api.claim() expected = {"DOC-1": ["DOCH-10", "DOCH-11", "DOCH-12"], "DOC-3": ["DOCH-30", "DOCH-31"]} assert cmd["params"]["known_versions"] == expected assert api.wiki.get(f"/agent/commands/{cmd['id']}")["params"]["known_versions"] == expected api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [ version("DOC-1", "DOCH-13", "2026-10-25T10:00:00Z", "

б

")]}) api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1, "versions": 1}) rows = {r["code"]: r for r in versions_of(api)} assert (rows["DOCH-13"]["added"], rows["DOCH-13"]["removed"]) == history.line_delta(rows["DOCH-12"]["markdown"], rows["DOCH-13"]["markdown"]) def test_agent_without_history(api): first_load(api) before = api.rows("SELECT history_at FROM wiki_history") old_sync = api.project()["wiki_synced_at"] api.enqueue() cmd = api.claim() api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) assert api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1})["status"] == "done" assert api.project()["wiki_synced_at"] >= old_sync assert len(versions_of(api)) == 5 and api.rows("SELECT history_at FROM wiki_history") == before def test_page_left_tree(api): first_load(api) api.enqueue() cmd, done = run_history(api, [TREE[0], TREE[2]], [], ["DOC-1"]) assert done["status"] == "done" assert {r["page_code"] for r in versions_of(api)} == {"DOC-1"} def test_foreign_page_version_dropped(api): _, done = run_history(api, TREE, [version("DOC-99", "DOCH-9", "2026-10-25T10:00:00Z")], CODES) assert done["status"] == "done" and versions_of(api) == [] def test_lost_versions_batch(api): first_load(api) api.enqueue() old_sync = api.project()["wiki_synced_at"] _, done = run_history(api, TREE, [version("DOC-1", f"DOCH-N{i}", "2026-10-26T10:00:00Z") for i in range(3)], CODES, reported=5) assert done["status"] == "failed" and done["error"] == "нет версий: пришло 3 из 5" assert api.project()["wiki_synced_at"] == old_sync and len(versions_of(api)) == 5 assert stage_count(api) == 0 and api.rows("SELECT COUNT(*) AS n FROM wiki_sync_stage")[0]["n"] == 0 def test_resend_counts_unique(api): cmd = api.claim() api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) batch = [version("DOC-1", "DOCH-1", "2026-10-25T10:00:00Z"), version("DOC-1", "DOCH-2", "2026-10-26T10:00:00Z")] for _ in range(2): api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch}) done = api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1, "versions": 2}) assert done["status"] == "done" and len(versions_of(api)) == 2 @pytest.mark.parametrize("bad", ["5", True, -1, 1.5]) def test_versions_field_bad(api, bad): _, done = run_history(api, TREE, [], CODES, reported=bad) assert done["status"] == "failed" and done["error"] == "versions — неотрицательное целое" def test_late_version_recounts_successor(api): run_history(api, TREE, [version("DOC-1", "V1", "2026-10-25T10:00:00Z", "

а

"), version("DOC-1", "V3", "2026-10-25T12:00:00Z", "

а

в

")], CODES) api.enqueue() run_history(api, TREE, [version("DOC-1", "V2", "2026-10-25T11:00:00Z", "

а

б

")], CODES) r = {x["code"]: x for x in versions_of(api)} assert (r["V2"]["added"], r["V2"]["removed"]) == history.line_delta(r["V1"]["markdown"], r["V2"]["markdown"]) assert (r["V3"]["added"], r["V3"]["removed"]) == history.line_delta(r["V2"]["markdown"], r["V3"]["markdown"]) assert (r["V3"]["added"], r["V3"]["removed"]) != history.line_delta(r["V1"]["markdown"], r["V3"]["markdown"]) def test_known_version_resent_ignored(api): first_load(api) before = versions_of(api) api.enqueue() run_history(api, TREE, [version("DOC-1", "DOCH-11", "2026-10-21T10:00:00Z", "

совсем другое

")], CODES) assert versions_of(api) == before def test_reset_root_drops_history(api): first_load(api) api.client.patch("/projects/AWPCA", {"wiki_root": None}) assert versions_of(api) == [] and api.rows("SELECT * FROM wiki_history") == [] def test_redelivery_clears_version_stage(api): cmd = api.claim() api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-1", "2026-10-25T10:00:00Z")]}) assert stage_count(api) == 1 again = api.claim() assert again["id"] == cmd["id"] and stage_count(api) == 0 def test_discarded_report_keeps_history(api): first_load(api) api.enqueue() cmd = api.claim() api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-X", "2026-10-26T10:00:00Z")]}) api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) c = api.hub.conn() try: c.execute("UPDATE wiki_project SET root_code = 'DOC-7'") c.commit() finally: c.close() done = api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1, "versions": 1}) assert done["result"]["discarded"] and len(versions_of(api)) == 5 and stage_count(api) == 0 def test_failed_report_clears_version_stage(api): first_load(api) api.enqueue() cmd = api.claim() api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-X", "2026-10-26T10:00:00Z")]}) api.report(cmd, "failed", None, "Eva: HTTP 503") assert stage_count(api) == 0 and len(versions_of(api)) == 5 def test_housekeeping_clears_version_stage(api): cmd = api.claim() api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-1", "2026-10-25T10:00:00Z")]}) c = api.hub.conn() try: c.execute("UPDATE command SET status = 'cancelled' WHERE id = ?", (cmd["id"],)) sync.housekeeping(c) c.commit() finally: c.close() assert stage_count(api) == 0 def test_history_heavy_work_off_write_lock(api, monkeypatch): seen = [] def probe(tag): other = sqlite3.connect(api.db_path, timeout=0) try: other.execute("BEGIN IMMEDIATE") other.execute("ROLLBACK") seen.append((tag, "free")) except sqlite3.OperationalError: seen.append((tag, "locked")) finally: other.close() real_md, real_delta = convert.to_markdown, sync.history.line_delta monkeypatch.setattr(stage.convert, "to_markdown", lambda *a, **k: (probe("markdown"), real_md(*a, **k))[1]) monkeypatch.setattr(sync.history, "line_delta", lambda *a: (probe("delta"), real_delta(*a))[1]) first_load(api) assert ("delta", "free") in seen and ("markdown", "free") in seen and not [s for s in seen if s[1] == "locked"] def test_line_delta(): assert history.line_delta("", "a\nb\n") == (2, 0) assert history.line_delta("a\nb\nc\n", "a\nB\nc\nd\n") == (2, 1) assert history.line_delta("a\n", "a\n") == (0, 0)