sdlc-hub/tests/test_wiki_history.py
2026-10-28 12:06:39 +04:00

339 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""История правок вики (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
# --- 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", "<p>а</p>" * (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", "<p>б</p>")]})
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", "<p>а</p>"),
version("DOC-1", "V3", "2026-10-25T12:00:00Z", "<p>а</p><p>в</p>")], CODES)
api.enqueue()
run_history(api, TREE, [version("DOC-1", "V2", "2026-10-25T11:00:00Z", "<p>а</p><p>б</p>")], 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", "<p>совсем другое</p>")], 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)