497 lines
29 KiB
Python
497 lines
29 KiB
Python
import json
|
||
|
||
import pytest
|
||
|
||
from sdlc_hub.core import db
|
||
from sdlc_hub.modules.jira import mirror
|
||
|
||
|
||
@pytest.fixture
|
||
def conn(open_db):
|
||
"""Пустое зеркало в памяти; закрывается после теста."""
|
||
return open_db()
|
||
|
||
|
||
def issue(key, **over):
|
||
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст",
|
||
"status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me",
|
||
"duedate": "2026-10-23", "planned_start": None, "planned_end": "2026-10-23", "epic_key": "AURORA-1350",
|
||
"labels": ["AWPCA"], "sprint": "Спринт 2", "created": "2026-10-10T12:00:00.000+0300",
|
||
"jira_updated": "2026-10-11T12:00:00.000+0300"}
|
||
base.update(over)
|
||
return base
|
||
|
||
|
||
def test_apply_state_upserts_and_replaces_links(conn):
|
||
links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}]
|
||
out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links)
|
||
assert out == {"issues": 2, "links": 1}
|
||
mirror.apply_state(conn, [issue("A-1", summary="Новая тема")],
|
||
[{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
|
||
got = mirror.get_issue(conn, "A-1")
|
||
assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"]
|
||
assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные", "summary": "Тема A-2",
|
||
"status": "Ожидание разработки"}]
|
||
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные",
|
||
"summary": "Новая тема", "status": "Ожидание разработки"}]
|
||
|
||
|
||
def test_links_from_other_source_survive(conn):
|
||
mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
|
||
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
|
||
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
|
||
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные",
|
||
"summary": "Тема A-2", "status": "Ожидание разработки"}]
|
||
|
||
|
||
def test_apply_state_marks_neighbors(conn):
|
||
mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"})
|
||
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
|
||
assert mirror.get_issue(conn, "A-1")["neighbor"] is False
|
||
mirror.apply_state(conn, [issue("B-9")], []) # вошла в базовую JQL
|
||
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
|
||
assert mirror.known_keys(conn) == ["A-1", "B-9"]
|
||
|
||
|
||
def test_partial_load_never_lowers_neighbor(conn):
|
||
mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"})
|
||
mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys}
|
||
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
|
||
mirror.apply_state(conn, [issue("B-9")], [], authoritative=True) # полный прогон: вошла в базовую
|
||
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
|
||
|
||
|
||
def test_note_survives_refresh_and_renames(conn):
|
||
mirror.set_note(conn, "new:7", {"rest_text": "В работе", "work_kind": "разработка"}, "import")
|
||
mirror.rename_note(conn, "new:7", "A-5")
|
||
mirror.apply_state(conn, [issue("A-5")], [])
|
||
got = mirror.get_issue(conn, "A-5")
|
||
assert got["note"]["rest_text"] == "В работе" and got["note"]["updated_by"] == "import"
|
||
assert mirror.get_note(conn, "new:7") is None
|
||
with pytest.raises(ValueError):
|
||
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
|
||
with pytest.raises(ValueError, match="неизвестные поля заметки: fact_status"): # fact-status-removal
|
||
mirror.set_note(conn, "A-5", {"fact_status": "В работе"}, "x")
|
||
|
||
|
||
def test_list_issues_filters(conn):
|
||
mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"),
|
||
issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]),
|
||
issue("A-3", sprint="Спринт 2", duedate=None)], [])
|
||
mirror.set_note(conn, "A-3", {"work_kind": "тестирование"}, "x")
|
||
keys = [i["key"] for i in mirror.list_issues(conn, {"sprint": "Спринт 2"})]
|
||
assert keys == ["A-1", "A-3"] # без срока — последней
|
||
assert [i["key"] for i in mirror.list_issues(conn, {"label": "fact-done"})] == ["A-2"]
|
||
assert [i["key"] for i in mirror.list_issues(conn, {"work_kind": "тестирование"})] == ["A-3"]
|
||
assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"]
|
||
assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None
|
||
assert len(mirror.list_issues(conn, None, limit=1)) == 1
|
||
|
||
|
||
def test_rename_note_same_key_keeps_note(conn):
|
||
mirror.apply_state(conn, [issue("A-1")], [])
|
||
mirror.set_note(conn, "A-1", {"rest_text": "В работе"}, "x")
|
||
mirror.rename_note(conn, "A-1", "A-1")
|
||
assert mirror.get_note(conn, "A-1")["rest_text"] == "В работе"
|
||
|
||
|
||
def test_rename_note_missing_is_noop(conn):
|
||
mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает
|
||
assert mirror.get_note(conn, "new:404") is None
|
||
assert mirror.get_note(conn, "A-9") is None
|
||
|
||
|
||
def test_set_note_partial_keeps_other_fields(conn):
|
||
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
|
||
got = mirror.set_note(conn, "A-1", {"rest_text": "Готово"}, "y")
|
||
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
|
||
assert got["rest_text"] == "Готово" and got["updated_by"] == "y"
|
||
|
||
|
||
def test_rename_note_merges_with_existing(conn):
|
||
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
|
||
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "rest_text": "В работе"}, "import")
|
||
mirror.rename_note(conn, "new:5", "A-5")
|
||
note = mirror.get_note(conn, "A-5")
|
||
assert note["work_kind"] == "Аналитика" # непустое поле временной заметки побеждает
|
||
assert note["owner_note"] == "спросить ИБ" # пустое у временной — остаётся под ключом
|
||
assert note["rest_text"] == "В работе"
|
||
assert note["updated_by"] == "import" # временная заметка записана позже
|
||
assert mirror.get_note(conn, "new:5") is None
|
||
|
||
|
||
def test_rename_note_keeps_newer_real_key_stamp(conn):
|
||
mirror.set_note(conn, "new:6", {"rest_text": "В работе"}, "import")
|
||
mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human")
|
||
conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'")
|
||
conn.execute("UPDATE issue_note SET updated_at = '2030-01-01T00:00:00+00:00' WHERE key = 'A-6'")
|
||
mirror.rename_note(conn, "new:6", "A-6")
|
||
note = mirror.get_note(conn, "A-6")
|
||
assert note["updated_by"] == "human" and note["updated_at"] == "2030-01-01T00:00:00+00:00"
|
||
assert note["rest_text"] == "В работе" and note["owner_note"] == "позже"
|
||
|
||
|
||
def test_rename_note_empty_string_does_not_overwrite(conn):
|
||
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
|
||
mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import")
|
||
mirror.rename_note(conn, "new:6", "A-6")
|
||
note = mirror.get_note(conn, "A-6")
|
||
assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано"
|
||
|
||
|
||
BASE = "project = AURORA AND labels = AWPCA"
|
||
|
||
|
||
def _sync_run(conn, started, finished, kind, query, status="done", count=5):
|
||
"""Строка журнала синхронизаций с командой refresh в нужном статусе."""
|
||
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('t', 'test', ?)", (started,))
|
||
batch_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
|
||
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at) VALUES (?, 1, 'refresh', '{}', ?, ?)",
|
||
(batch_id, status, finished))
|
||
command_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
|
||
conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query, issues_count, command_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||
(started, finished, kind, query, count, command_id))
|
||
|
||
|
||
def test_board_snapshot_raw_mirror_data(conn):
|
||
mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None),
|
||
issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"),
|
||
issue("AURORA-1621", epic_key="AURORA-1351")],
|
||
[{"source_key": "AURORA-1620", "target_key": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}],
|
||
neighbor_keys=["AURORA-1621"])
|
||
mirror.set_note(conn, "AURORA-1620", {"work_kind": "разработка"}, "test")
|
||
snap = mirror.board(conn, BASE)
|
||
assert [i["key"] for i in snap["issues"]] == ["AURORA-1351", "AURORA-1620", "AURORA-1621"]
|
||
a = snap["issues"][1]
|
||
assert set(a) == {"key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate",
|
||
"planned_start", "planned_end", "epic_key", "labels", "neighbor", "work_kind"}
|
||
assert a["labels"] == ["kind-dev"] and a["epic_key"] == "AURORA-1351"
|
||
assert a["work_kind"] == "разработка" and a["neighbor"] is False
|
||
assert snap["issues"][2]["neighbor"] is True and snap["issues"][2]["work_kind"] is None
|
||
assert snap["links"] == [{"source": "AURORA-1620", "target": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}]
|
||
assert snap["last_sync"] is None and snap["generated_at"]
|
||
|
||
|
||
def test_board_last_sync_is_last_successful_full_refresh(conn):
|
||
_sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198)
|
||
_sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1)
|
||
_sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1)
|
||
_sync_run(conn, "2026-10-14T12:00:00+00:00", "2026-10-14T12:01:00+00:00", "jql", BASE, status="failed", count=0)
|
||
_sync_run(conn, "2026-10-14T13:00:00+00:00", None, "jql", BASE, status="claimed", count=50)
|
||
assert mirror.board(conn, BASE)["last_sync"] == {"started_at": "2026-10-14T09:59:00+00:00",
|
||
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
|
||
|
||
|
||
def test_board_inside_open_transaction_keeps_it_open(conn):
|
||
"""M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою."""
|
||
mirror.apply_state(conn, [issue("AURORA-1620")], [])
|
||
with db.transaction(conn):
|
||
assert conn.in_transaction
|
||
snap = mirror.board(conn, BASE)
|
||
assert conn.in_transaction
|
||
assert not conn.in_transaction
|
||
assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"]
|
||
|
||
|
||
def comment(cid, created="2026-10-14T12:00:00.000+0300", **over):
|
||
base = {"id": cid, "author": "Петров Пётр", "created": created, "updated": created, "body": f"текст {cid}"}
|
||
base.update(over)
|
||
return base
|
||
|
||
|
||
def test_comments_replaced_and_kept_without_field(conn):
|
||
mirror.apply_state(conn, [issue("A-1", comments=[comment("101"), comment("102")])], [])
|
||
mirror.apply_state(conn, [issue("A-1", comments=[comment("102", body="новый текст")])], [])
|
||
got = mirror.get_issue(conn, "A-1")["comments"]
|
||
assert [(c["comment_id"], c["body"]) for c in got] == [("102", "новый текст")]
|
||
mirror.apply_state(conn, [issue("A-1", summary="Без поля comments")], []) # агент до v10
|
||
assert [c["comment_id"] for c in mirror.get_issue(conn, "A-1")["comments"]] == ["102"]
|
||
mirror.apply_state(conn, [issue("A-1", comments=[])], [])
|
||
assert mirror.get_issue(conn, "A-1")["comments"] == []
|
||
|
||
|
||
def test_comments_newest_first_and_shape(conn):
|
||
mirror.apply_state(conn, [issue("A-1", comments=[
|
||
comment("9", created="2026-10-14T10:00:00.000+0300"),
|
||
comment("10", created="2026-10-15T10:00:00.000+0300", author=None, body=None),
|
||
comment("11", created="2026-10-15T10:00:00.000+0300")])], [])
|
||
got = mirror.get_issue(conn, "A-1")["comments"]
|
||
assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число
|
||
assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300",
|
||
"updated": "2026-10-15T10:00:00.000+0300", "body": "", "attachment_ids": []}
|
||
|
||
|
||
def test_issue_epic_summary_and_absent_link(conn):
|
||
mirror.apply_state(conn, [issue("A-1", epic_key="E-1"), issue("E-1", summary="Эпик свода", epic_key=None)],
|
||
[{"source_key": "A-1", "target_key": "Z-9", "link_type": "ASUPR_Связанные"}])
|
||
got = mirror.get_issue(conn, "A-1")
|
||
assert got["epic_summary"] == "Эпик свода"
|
||
assert got["links_out"] == [{"target_key": "Z-9", "link_type": "ASUPR_Связанные", "summary": None, "status": None}]
|
||
assert mirror.get_issue(conn, "E-1")["epic_summary"] is None
|
||
|
||
|
||
@pytest.mark.parametrize("bad, field", [
|
||
("x", "comments"), (["x"], "comments[0]"), ([{"id": "", "body": "т"}], "comments[0].id"),
|
||
([{"id": 5, "body": "т"}], "comments[0].id"), ([{"id": "5", "body": 42}], "comments[0].body"),
|
||
([{"id": "5", "body": "т", "author": 7}], "comments[0].author"),
|
||
([{"id": "5", "body": "т", "created": 1}], "comments[0].created"),
|
||
([{"id": "5", "body": "т", "updated": []}], "comments[0].updated"),
|
||
])
|
||
def test_check_comments_rejects(bad, field):
|
||
with pytest.raises(ValueError) as e:
|
||
mirror.check_comments([issue("A-1"), issue("A-2", comments=bad)])
|
||
assert str(e.value).startswith(f"A-2: {field}")
|
||
|
||
|
||
def test_check_comments_accepts_missing_and_valid():
|
||
mirror.check_comments([issue("A-1"), issue("A-2", comments=[comment("1"), comment("2", author=None, body="")])])
|
||
|
||
|
||
def test_list_issues_hide_closed(conn):
|
||
mirror.apply_state(conn, [issue("A-1", status="В работе"), issue("A-2", status="Готово"),
|
||
issue("A-3", status="Снято"), issue("A-4", status=None)], [])
|
||
closed = ["Готово", "Снято"]
|
||
|
||
def keys(f, c=closed):
|
||
return sorted(i["key"] for i in mirror.list_issues(conn, f, closed_statuses=c))
|
||
assert keys({"hide_closed": "1"}) == ["A-1", "A-4"] # без статуса — не закрыта
|
||
assert keys({"hide_closed": "true"}) == ["A-1", "A-4"]
|
||
assert keys({}) == ["A-1", "A-2", "A-3", "A-4"]
|
||
assert keys({"hide_closed": "0"}) == ["A-1", "A-2", "A-3", "A-4"]
|
||
assert keys({"hide_closed": "1"}, []) == ["A-1", "A-2", "A-3", "A-4"] # пустой набор — ничего не скрыто
|
||
assert keys({"hide_closed": "1", "status": "Готово"}) == [] # отменяет скрытие приложение, не сервер
|
||
|
||
|
||
PROJ = "STOCKS_DRRK"
|
||
|
||
|
||
def _scene(conn): # сценарий data-model «Свои и соседи» + hub-api «Снимок проекта»
|
||
mirror.apply_state(conn, [
|
||
issue("AURORA-1", labels=["STOCKS_DRRK"], epic_key="AURORA-9"),
|
||
issue("AURORA-2", labels=["AWPCA"], epic_key=None),
|
||
issue("AURORA-3", labels=["AWPCA"], epic_key=None),
|
||
issue("AURORA-4", labels=["STOCKS_DRRK", "kind-dev"], epic_key="AURORA-404"), # эпика нет в зеркале
|
||
issue("AURORA-5", labels=["AWPCA"], epic_key=None),
|
||
issue("AURORA-9", labels=["AWPCA"], issue_type="Фича", epic_key=None)],
|
||
[{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "L"},
|
||
{"source_key": "AURORA-2", "target_key": "AURORA-3", "link_type": "L"},
|
||
{"source_key": "AURORA-5", "target_key": "AURORA-4", "link_type": "L"}, # входящая — тоже сосед
|
||
{"source_key": "AURORA-4", "target_key": "AURORA-777", "link_type": "L"}], # конца нет в зеркале
|
||
neighbor_keys=["AURORA-1"]) # признак зеркала не важен в разрезе
|
||
|
||
|
||
def test_board_project_scope(conn):
|
||
_scene(conn)
|
||
snap = mirror.board(conn, BASE, project=PROJ)
|
||
assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [
|
||
("AURORA-1", False), ("AURORA-2", True), ("AURORA-4", False), ("AURORA-5", True), ("AURORA-9", True)]
|
||
assert {(l["source"], l["target"]) for l in snap["links"]} == {
|
||
("AURORA-1", "AURORA-2"), ("AURORA-5", "AURORA-4"), ("AURORA-4", "AURORA-777")}
|
||
assert len(mirror.board(conn, BASE)["issues"]) == 6 # без проекта — всё зеркало
|
||
|
||
|
||
def test_board_project_last_sync_same(conn):
|
||
_scene(conn)
|
||
assert mirror.board(conn, BASE, project=PROJ)["last_sync"] == mirror.board(conn, BASE)["last_sync"]
|
||
|
||
|
||
def test_list_issues_project(conn): # сценарий «Список проекта»
|
||
_scene(conn)
|
||
keys = lambda **f: [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, **f})]
|
||
assert sorted(keys()) == ["AURORA-1", "AURORA-2", "AURORA-4", "AURORA-5", "AURORA-9"]
|
||
assert sorted(keys(neighbor="1")) == ["AURORA-2", "AURORA-5", "AURORA-9"]
|
||
assert sorted(keys(neighbor="0")) == ["AURORA-1", "AURORA-4"]
|
||
assert keys(label="kind-dev") == ["AURORA-4"]
|
||
assert {i["key"]: i["neighbor"] for i in mirror.list_issues(conn, {"project": PROJ})}["AURORA-1"] is False
|
||
|
||
|
||
def test_list_issues_project_paging_and_filters(conn):
|
||
_scene(conn)
|
||
page = mirror.list_issues(conn, {"project": PROJ}, limit=2, offset=1)
|
||
assert [i["key"] for i in page] == ["AURORA-2", "AURORA-4"]
|
||
assert [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, "q": "AURORA-5"})] == ["AURORA-5"]
|
||
|
||
|
||
def test_labels_of(conn):
|
||
_scene(conn)
|
||
assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"]
|
||
assert mirror.labels_of(conn, "AURORA-404") is None
|
||
|
||
|
||
def test_apply_state_comment_created_at(conn):
|
||
mirror.apply_state(conn, [{"key": "X", "comments": [
|
||
{"id": "1", "body": "a", "created": "2026-10-22T13:05:11.000+0300"},
|
||
{"id": "2", "body": "b", "created": "мусор"}]}], [])
|
||
rows = {r["comment_id"]: r["created_at"] for r in conn.execute("SELECT * FROM issue_comment")}
|
||
assert rows == {"1": "2026-10-22T10:05:11+00:00", "2": None}
|
||
|
||
|
||
def attachment(aid, **over):
|
||
base = {"id": aid, "filename": f"файл{aid}.png", "size": 1000, "mime_type": "image/png", "author": "Петров Пётр",
|
||
"created": "2026-10-14T12:00:00.000+0300"}
|
||
base.update(over)
|
||
return base
|
||
|
||
|
||
def _atts(conn, key="A-1"):
|
||
return {r["attachment_id"]: dict(r) for r in conn.execute("SELECT * FROM issue_attachment WHERE key = ?", (key,))}
|
||
|
||
|
||
def test_attachments_replace(conn):
|
||
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1"), attachment("2")])], [])
|
||
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("2", filename="новое.pdf", size=5), attachment("3")])], [])
|
||
got = _atts(conn)
|
||
assert sorted(got) == ["2", "3"]
|
||
assert got["2"]["filename"] == "новое.pdf" and got["2"]["size"] == 5
|
||
assert got["2"]["created_at"] == "2026-10-14T09:00:00+00:00"
|
||
|
||
|
||
def test_attachments_absent_kept(conn):
|
||
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], [])
|
||
mirror.apply_state(conn, [issue("A-1", summary="Без поля")], [])
|
||
assert sorted(_atts(conn)) == ["1"]
|
||
|
||
|
||
def test_attachment_file_untouched(conn):
|
||
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], [])
|
||
conn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('1', 'abc', 1000, '2026-10-22T10:00:00+00:00')")
|
||
mirror.apply_state(conn, [issue("A-1", attachments=[])], [])
|
||
assert _atts(conn) == {}
|
||
assert conn.execute("SELECT file FROM attachment_file WHERE attachment_id = '1'").fetchone()["file"] == "abc"
|
||
|
||
|
||
def test_attachment_moves_to_other_key(conn):
|
||
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], [])
|
||
mirror.apply_state(conn, [issue("A-2", attachments=[attachment("1")])], [])
|
||
assert _atts(conn, "A-1") == {} and sorted(_atts(conn, "A-2")) == ["1"]
|
||
|
||
|
||
@pytest.mark.parametrize("bad, field", [
|
||
("x", "attachments"), (["x"], "attachments[0]"),
|
||
([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": "1a", "filename": "a"}], "attachments[0].id"),
|
||
([{"id": "-5", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"),
|
||
([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"),
|
||
([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"),
|
||
([{"id": "5", "filename": "a", "size": "1"}], "attachments[0].size"),
|
||
([{"id": "5", "filename": "a", "size": True}], "attachments[0].size"),
|
||
([{"id": "5", "filename": "a", "mime_type": 5}], "attachments[0].mime_type"),
|
||
])
|
||
def test_check_attachments_errors(bad, field):
|
||
with pytest.raises(ValueError) as e:
|
||
mirror.check_attachments([issue("A-1"), issue("A-2", attachments=bad)])
|
||
assert str(e.value).startswith(f"A-2: {field}")
|
||
|
||
|
||
def test_check_attachments_accepts_missing_and_valid():
|
||
mirror.check_attachments([issue("A-1"), issue("A-2", attachments=[attachment("1"), attachment("2", size=None, mime_type=None)])])
|
||
|
||
|
||
def test_get_issue_attachments(conn):
|
||
mirror.apply_state(conn, [issue(
|
||
"A-1", description="см. !старый.png!",
|
||
attachments=[attachment("31337", filename="скрин 1.png", created="2026-10-14T12:00:00.000+0300"),
|
||
attachment("31338", filename="ОСВ 60.xlsx", created="2026-10-15T12:00:00.000+0300")],
|
||
comments=[comment("101", body="см. !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!"),
|
||
comment("102", body="без ссылок")])], [])
|
||
got = mirror.get_issue(conn, "A-1")
|
||
assert [a["attachment_id"] for a in got["attachments"]] == ["31338", "31337"]
|
||
assert got["attachments"][0]["state"] == "none" and got["attachments"][0]["error"] is None
|
||
assert got["description_attachment_ids"] == []
|
||
by_id = {c["comment_id"]: c for c in got["comments"]}
|
||
assert by_id["101"]["attachment_ids"] == ["31337", "31338"] and by_id["102"]["attachment_ids"] == []
|
||
|
||
|
||
def test_get_issue_commands_skip_fetch(conn):
|
||
mirror.apply_state(conn, [issue("A-1")], [])
|
||
conn.execute("INSERT INTO batch(id, title, author, created_at) VALUES (1, 't', 'a', ?)", (db.now(),))
|
||
for seq, typ in enumerate(["fetch_attachment", "ensure_fields"]):
|
||
conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (1, ?, ?, ?)",
|
||
(seq, typ, '{"key": "A-1", "attachment_id": "1"}'))
|
||
assert [c["type"] for c in mirror.get_issue(conn, "A-1")["commands"]] == ["ensure_fields"]
|
||
|
||
|
||
COLS = [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}]
|
||
|
||
|
||
def board(id=4216, type="scrum", sprints=(), **over):
|
||
return {"id": id, "type": type, "columns": COLS, "kanban_backlog": None, "sprints": list(sprints), **over}
|
||
|
||
|
||
def sprint(id, state, name=None, **over):
|
||
return {"id": id, "name": name or f"S{id}", "state": state, "start_date": None, "end_date": None,
|
||
"complete_date": None, "goal": None, **over}
|
||
|
||
|
||
def sprint_rows(conn, key):
|
||
return sorted(r[0] for r in conn.execute("SELECT sprint_id FROM issue_sprint WHERE key = ?", (key,)))
|
||
|
||
|
||
def test_state_issue_in_two_sprints(conn): # сценарий «Задача прошла два спринта»
|
||
mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], [], boards=[board(sprints=[sprint(101, "closed"), sprint(102, "active")])])
|
||
assert sprint_rows(conn, "X") == [101, 102]
|
||
|
||
|
||
def test_state_sprint_ids_replace_keep_clear(conn):
|
||
mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], [])
|
||
mirror.apply_state(conn, [issue("X", sprint_ids=[102])], []); assert sprint_rows(conn, "X") == [102]
|
||
mirror.apply_state(conn, [issue("X")], []); assert sprint_rows(conn, "X") == [102] # агент v14
|
||
mirror.apply_state(conn, [issue("X", sprint_ids=[])], []); assert sprint_rows(conn, "X") == []
|
||
|
||
|
||
def test_state_rank_and_points_set_keep_clear(conn):
|
||
mirror.apply_state(conn, [issue("X", rank="0|i0001:", story_points=3)], [])
|
||
mirror.apply_state(conn, [issue("X", summary="другая")], []) # без полей — прежние значения
|
||
row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone()
|
||
assert (row["rank"], row["story_points"]) == ("0|i0001:", 3.0)
|
||
mirror.apply_state(conn, [issue("X", rank=None, story_points=None)], [])
|
||
row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone()
|
||
assert (row["rank"], row["story_points"]) == (None, None)
|
||
|
||
|
||
def test_state_board_replaces_row_and_sprints(conn): # сценарий «Пачка с досками»
|
||
mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active", "старое"), sprint(102, "future")]),
|
||
board(id=5120, type="kanban", sprints=[sprint(201, "active")])])
|
||
new = [sprint(101, "closed", complete_date="2026-10-14T18:00:00.000+03:00"), sprint(102, "active"), sprint(103, "future")]
|
||
mirror.apply_state(conn, [], [], boards=[board(columns=COLS[:1], sprints=new)])
|
||
rows = {r["sprint_id"]: dict(r) for r in conn.execute("SELECT * FROM jira_sprint WHERE board_id = 4216")}
|
||
assert set(rows) == {101, 102, 103} and rows[101]["state"] == "closed" and rows[101]["name"] == "S101"
|
||
assert rows[101]["complete_date"] == "2026-10-14T18:00:00.000+03:00"
|
||
b = conn.execute("SELECT * FROM jira_board WHERE board_id = 4216").fetchone()
|
||
assert json.loads(b["columns"]) == COLS[:1] and b["kanban_backlog"] is None and b["fetched_at"]
|
||
assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint WHERE board_id = 5120")] == [201] # не в пачке — не тронута
|
||
|
||
|
||
def test_state_board_without_sprints_keeps_them(conn): # R-14
|
||
mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active")])])
|
||
mirror.apply_state(conn, [], [], boards=[{**board(), "sprints": None, "columns": None}])
|
||
assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint")] == [101]
|
||
assert conn.execute("SELECT columns FROM jira_board").fetchone()[0] is None
|
||
mirror.apply_state(conn, [], [], boards=[board(sprints=[])])
|
||
assert conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 0
|
||
|
||
|
||
def test_state_board_type_to_all_projects_of_board(conn, add_project): # Review Focus 3
|
||
add_project(conn, board_type=None); add_project(conn, "C", board_id=4216, board_type=None)
|
||
mirror.apply_state(conn, [], [], boards=[board(type="kanban", kanban_backlog=True)])
|
||
assert {r["label"]: r["board_type"] for r in conn.execute("SELECT label, board_type FROM jira_project")} == {"AWPCA": "kanban", "C": "kanban"}
|
||
assert tuple(conn.execute("SELECT type, kanban_backlog FROM jira_board").fetchone()) == ("kanban", 1)
|
||
mirror.apply_state(conn, [], [], boards=[board(type=None)]) # null тип проекта не меняет
|
||
assert conn.execute("SELECT board_type FROM jira_project WHERE label = 'AWPCA'").fetchone()[0] == "kanban"
|
||
|
||
|
||
def test_state_v14_keeps_agile_data(conn): # сценарий «Агент v14»
|
||
mirror.apply_state(conn, [issue("X", rank="r", story_points=2, sprint_ids=[101])], [], boards=[board(sprints=[sprint(101, "active")])])
|
||
mirror.apply_state(conn, [issue("X")], [])
|
||
assert sprint_rows(conn, "X") == [101] and conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1
|
||
assert tuple(conn.execute("SELECT rank, story_points FROM issue").fetchone()) == ("r", 2.0)
|
||
|
||
|
||
def test_check_agile_fields_ok_and_absent():
|
||
mirror.check_agile_fields([{"key": "A"}, {"key": "B", "rank": None, "story_points": 0.5, "sprint_ids": []},
|
||
{"key": "C", "rank": "r", "story_points": 3, "sprint_ids": [1, 2]}])
|
||
|
||
|
||
def test_check_boards_ok_and_none():
|
||
mirror.check_boards(None)
|
||
mirror.check_boards([board(sprints=[sprint(1, "closed", start_date="2026-10-01")]), {"id": 5, "type": None}])
|