339 lines
16 KiB
Python
339 lines
16 KiB
Python
"""История правок вики (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)
|