1294 lines
74 KiB
Python
1294 lines
74 KiB
Python
import hashlib
|
||
|
||
import pytest
|
||
|
||
from sdlc_hub.core import api, db, files, queue, settings
|
||
from sdlc_hub.modules.jira import attachments, commands, mirror, refresh
|
||
from sdlc_hub.modules.jira import fetch as jira_fetch
|
||
from sdlc_hub.modules.jira import projects as jira_projects
|
||
from sdlc_hub.modules.jira.commands import CommandError
|
||
from sdlc_hub.core.queue import ClaimedError
|
||
|
||
LT = {"связанные": "ASUPR_Связанные"}
|
||
|
||
|
||
@pytest.fixture
|
||
def conn(open_db):
|
||
"""База очереди в памяти с настройками по умолчанию и типами связей LT; закрывается после теста."""
|
||
c = open_db()
|
||
settings.ensure_defaults(c)
|
||
settings.set_(c, "link_types", LT)
|
||
return c
|
||
|
||
|
||
def fields(key, **extra):
|
||
return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}}
|
||
|
||
|
||
def test_create_batch_adds_tail_refresh_and_estimate(conn):
|
||
b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}])
|
||
view = queue.batch_view(conn, b["id"])
|
||
assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"]
|
||
assert view["commands"][2]["params"] == {"keys": ["A-1", "A-2"]}
|
||
assert b["estimate_seconds"] == 120 + 30 + 2 and b["count"] == 3
|
||
assert view["status"] == "queued" and view["author"] == "claude"
|
||
|
||
|
||
def test_refresh_only_batch_has_no_tail(conn):
|
||
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
||
assert b["count"] == 1
|
||
with pytest.raises(CommandError):
|
||
queue.create_batch(conn, "x", "claude", [{"type": "nope"}])
|
||
|
||
|
||
def test_claim_order_pause_and_report(conn):
|
||
b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")])
|
||
b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")])
|
||
c = queue.claim(conn, "jira", agent_version=1, settings_version=1)
|
||
assert c["type"] == "ensure_fields" and c["params"]["key"] == "A-1" and c["pause_seconds"] == 120 and c["batch_id"] == b1["id"]
|
||
again = queue.claim(conn, "jira") # ответ потерян — та же команда снова
|
||
assert again["id"] == c["id"] and again["pause_seconds"] == 120
|
||
assert queue.get_command(conn, c["id"])["deliveries"] == 2
|
||
assert queue.agent_view(conn, "jira")["current_command_id"] == c["id"]
|
||
done = queue.report_result(conn, c["id"], "done", result={"changed": ["duedate"]}, jira_calls=2)
|
||
assert done["status"] == "done" and done["jira_calls"] == 2
|
||
again = queue.report_result(conn, c["id"], "failed", error="поздно")
|
||
assert again["status"] == "done" # повторный отчёт не меняет
|
||
tail = queue.claim(conn, "jira")
|
||
assert tail["type"] == "refresh" and tail["pause_seconds"] == 2
|
||
assert conn.execute("SELECT kind, query FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()["query"] == "A-1"
|
||
refresh.record_state(conn, tail["id"], 1, 0)
|
||
queue.report_result(conn, tail["id"], "done", jira_calls=1)
|
||
run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()
|
||
assert run["finished_at"] and run["issues_count"] == 1 and run["jira_calls"] == 1
|
||
assert queue.batch_view(conn, b1["id"])["status"] == "done"
|
||
assert queue.claim(conn, "jira")["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
|
||
|
||
|
||
def test_claim_is_exclusive(tmp_path, open_db):
|
||
import threading
|
||
path = tmp_path / "hub.sqlite"
|
||
setup = open_db(path)
|
||
settings.ensure_defaults(setup)
|
||
queue.create_batch(setup, "x", "claude", [fields("A-1")])
|
||
conns = [open_db(path), open_db(path)]
|
||
barrier, results = threading.Barrier(2), []
|
||
|
||
def worker(c):
|
||
barrier.wait()
|
||
results.append(queue.claim(c, "jira"))
|
||
|
||
threads = [threading.Thread(target=worker, args=(c,)) for c in conns]
|
||
[t.start() for t in threads]
|
||
[t.join() for t in threads]
|
||
got = [r for r in results if r is not None]
|
||
assert got and len({r["id"] for r in got}) == 1 # один и тот же id при любом порядке потоков
|
||
row = setup.execute("SELECT COUNT(*) AS n, MAX(deliveries) AS d FROM command WHERE status = 'claimed'").fetchone()
|
||
assert row["n"] == 1 and row["d"] == len(got)
|
||
|
||
|
||
def test_backup_failure_does_not_block_claim(conn, caplog):
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||
|
||
def broken(label):
|
||
raise OSError("диск полон")
|
||
|
||
c = queue.claim(conn, "jira", backup=broken)
|
||
assert c is not None and c["params"]["key"] == "A-1"
|
||
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||
assert "не создана" in caplog.text
|
||
|
||
|
||
def test_temp_key_substitution_and_skip(conn, add_project):
|
||
add_project(conn)
|
||
mirror.set_note(conn, "new:60", {"rest_text": "В работе"}, "import")
|
||
queue.create_batch(conn, "создать", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
|
||
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая",
|
||
"labels": ["AWPCA"]}},
|
||
{"type": "ensure_sprint", "params": {"key": "new:61", "sprint": "Спринт 2"}},
|
||
])
|
||
c1 = queue.claim(conn, "jira")
|
||
queue.report_result(conn, c1["id"], "done", result={"key": "A-90"})
|
||
assert mirror.get_note(conn, "A-90")["rest_text"] == "В работе" and mirror.get_note(conn, "new:60") is None
|
||
c2 = queue.claim(conn, "jira")
|
||
assert c2["type"] == "ensure_link" and c2["params"]["source"] == "A-90"
|
||
queue.report_result(conn, c2["id"], "done")
|
||
c3 = queue.claim(conn, "jira")
|
||
queue.report_result(conn, c3["id"], "failed", error="HTTP 400")
|
||
tail = queue.claim(conn, "jira") # ensure_sprint пропущена, refresh без new:61
|
||
assert tail["type"] == "refresh" and tail["params"]["keys"] == ["A-90", "A-1"]
|
||
view = queue.batch_view(conn, c1["batch_id"])
|
||
assert [c["status"] for c in view["commands"]][:4] == ["done", "done", "failed", "skipped"]
|
||
assert view["commands"][3]["error"] == "задача new:61 не создана"
|
||
|
||
|
||
def test_cancel_and_retry(conn):
|
||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||
c = queue.claim(conn, "jira")
|
||
with pytest.raises(ClaimedError):
|
||
queue.cancel_command(conn, c["id"])
|
||
view = queue.cancel_batch(conn, b["id"])
|
||
assert [x["status"] for x in view["commands"]] == ["claimed", "cancelled", "cancelled"]
|
||
assert queue.claim(conn, "jira")["id"] == c["id"] # взятая из отменённого пакета довыдаётся
|
||
forced = queue.cancel_command(conn, c["id"], force=True)
|
||
assert forced["status"] == "cancelled"
|
||
after = queue.report_result(conn, c["id"], "done", result={"changed": []})
|
||
assert after["status"] == "cancelled" and after["result"] is None # отчёт после force-отмены не затирает
|
||
assert queue.batch_view(conn, b["id"])["status"] == "cancelled"
|
||
r = queue.retry_command(conn, c["id"], "claude")
|
||
assert queue.batch_view(conn, r["id"])["title"] == f"Повтор команды {c['id']}"
|
||
assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"]
|
||
|
||
|
||
def test_report_after_force_cancel_keeps_cancelled(conn):
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||
c = queue.claim(conn, "jira")
|
||
queue.cancel_command(conn, c["id"], force=True)
|
||
assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled"
|
||
|
||
|
||
def test_reconcile_confirms_by_state(conn, add_project):
|
||
add_project(conn)
|
||
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], [])
|
||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
|
||
c = queue.claim(conn, "jira")
|
||
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
r = queue.batch_view(conn, b["id"])["commands"][0]
|
||
conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, deliveries = 1 WHERE id = ?", (db.now(), r["id"]))
|
||
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], [])
|
||
queue.report_result(conn, r["id"], "done")
|
||
assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state"
|
||
assert queue.batch_view(conn, b["id"])["status"] == "done"
|
||
|
||
|
||
def test_auto_refresh_needs_projects(conn):
|
||
queue.claim(conn, "jira", agent_version=1, settings_version=1)
|
||
assert refresh.maybe_auto_refresh(conn) is None # агент на связи, очередь пуста, проектов нет
|
||
|
||
|
||
def test_auto_refresh_two_projects(conn, add_project): # сценарий «Два проекта»
|
||
add_project(conn)
|
||
add_project(conn, "STOCKS_DRRK", board_id=None)
|
||
queue.claim(conn, "jira", agent_version=1, settings_version=1)
|
||
b = refresh.maybe_auto_refresh(conn)
|
||
assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}
|
||
|
||
|
||
def test_auto_refresh_conditions(conn, add_project):
|
||
add_project(conn)
|
||
assert refresh.maybe_auto_refresh(conn) is None # агент не на связи
|
||
queue.claim(conn, "jira", agent_version=1, settings_version=1) # отметился, очередь пуста
|
||
b = refresh.maybe_auto_refresh(conn)
|
||
assert b and queue.batch_view(conn, b["id"])["title"] == "Автообновление"
|
||
assert refresh.maybe_auto_refresh(conn) is None # очередь занята
|
||
c = queue.claim(conn, "jira")
|
||
queue.report_result(conn, c["id"], "done")
|
||
assert refresh.maybe_auto_refresh(conn) is None # интервал не прошёл
|
||
conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'")
|
||
assert refresh.maybe_auto_refresh(conn) is not None
|
||
settings.set_(conn, "auto_refresh_minutes", 0)
|
||
conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'")
|
||
assert refresh.maybe_auto_refresh(conn) is None
|
||
|
||
|
||
def test_backup_called_for_first_write_only(conn, tmp_path):
|
||
calls = []
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||
c1 = queue.claim(conn, "jira", backup=calls.append)
|
||
queue.report_result(conn, c1["id"], "done")
|
||
queue.claim(conn, "jira", backup=calls.append)
|
||
assert calls == [f"batch-{c1['batch_id']}"]
|
||
|
||
|
||
def test_no_backup_for_refresh_batch(conn):
|
||
calls = []
|
||
queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
||
queue.claim(conn, "jira", backup=calls.append)
|
||
assert calls == []
|
||
|
||
|
||
def test_make_backup_rotates(tmp_path, open_db):
|
||
from sdlc_hub.core import backup
|
||
conn = open_db(tmp_path / "hub.sqlite")
|
||
for i in range(4):
|
||
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
|
||
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
|
||
assert len(names) == 2 and names[-1].endswith("-b3.sqlite")
|
||
assert open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2
|
||
|
||
|
||
def test_rotation_keeps_schema_migration_copy(tmp_path, open_db):
|
||
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
|
||
from sdlc_hub.core import backup
|
||
conn = open_db(tmp_path / "hub.sqlite")
|
||
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
|
||
for i in range(4):
|
||
backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2)
|
||
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
|
||
assert len([n for n in names if n.endswith("-schema-v1.sqlite")]) == 1
|
||
assert len([n for n in names if "-batch-" in n]) == 2
|
||
|
||
|
||
def _claimed_ensure_issue(conn, summary="Тема"):
|
||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}])
|
||
c = queue.claim(conn, "jira")
|
||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
|
||
return c
|
||
|
||
|
||
def test_reconcile_does_not_confirm_ensure_issue_without_reporter(conn):
|
||
"""ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""
|
||
c = _claimed_ensure_issue(conn)
|
||
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
|
||
created = db.utcnow().isoformat()
|
||
mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], [])
|
||
assert queue.reconcile(conn) == []
|
||
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||
assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None
|
||
|
||
|
||
def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch, add_project):
|
||
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
|
||
add_project(conn)
|
||
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], [])
|
||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
|
||
c = queue.claim(conn, "jira")
|
||
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": ["AWPCA"]}], [])
|
||
real = commands.check_confirmed
|
||
|
||
def racing(*a, **kw):
|
||
out = real(*a, **kw)
|
||
queue.cancel_command(conn, c["id"], force=True) # гонка: отмена после проверки
|
||
return out
|
||
|
||
monkeypatch.setattr(commands, "check_confirmed", racing)
|
||
assert queue.reconcile(conn) == []
|
||
assert queue.get_command(conn, c["id"])["status"] == "cancelled"
|
||
|
||
|
||
def _two_batches_with_dependent(conn):
|
||
a = queue.create_batch(conn, "A", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}},
|
||
{"type": "ensure_link", "params": {"source": "new:1", "target": "A-1", "link_type": "связанные"}}])
|
||
first = queue.claim(conn, "jira")
|
||
assert first["type"] == "ensure_issue"
|
||
b = queue.create_batch(conn, "B", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
||
return a, b, first
|
||
|
||
|
||
def test_redelivered_definer_blocks_dependents(conn):
|
||
a, b, first = _two_batches_with_dependent(conn)
|
||
assert queue.claim(conn, "jira")["id"] == first["id"] # довыдача определителя, зависимые ждут
|
||
conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],))
|
||
nxt = queue.claim(conn, "jira")
|
||
assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9"
|
||
|
||
|
||
def test_auto_refresh_waits_for_claimed_without_report(conn):
|
||
queue.claim(conn, "jira", agent_version=1, settings_version=1) # агент на связи
|
||
queue.create_batch(conn, "y", "claude", [fields("A-3")])
|
||
queue.claim(conn, "jira") # выдана, отчёта нет
|
||
conn.execute("DELETE FROM command WHERE status = 'queued'") # хвостовой refresh не мешает проверке
|
||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE status = 'claimed'") # давно без отчёта
|
||
assert refresh.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята
|
||
|
||
|
||
def test_redelivery_of_lost_claim_before_queue(conn):
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||
c = queue.claim(conn, "jira")
|
||
again = queue.claim(conn, "jira")
|
||
assert again["id"] == c["id"] and again["params"] == c["params"]
|
||
assert queue.agent_view(conn, "jira")["current_command_id"] == c["id"]
|
||
view = queue.agent_view(conn, "jira")["redelivered"]
|
||
assert [(x["id"], x["deliveries"]) for x in view] == [(c["id"], 2)]
|
||
queue.report_result(conn, c["id"], "done")
|
||
assert queue.claim(conn, "jira")["params"]["key"] == "A-2"
|
||
assert queue.agent_view(conn, "jira")["redelivered"] == []
|
||
|
||
|
||
def test_redelivery_limit_fails_command(conn):
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||
c = queue.claim(conn, "jira")
|
||
queue.claim(conn, "jira")
|
||
queue.claim(conn, "jira") # третья выдача
|
||
nxt = queue.claim(conn, "jira") # четвёртой нет — failed и дальше по очереди
|
||
assert nxt["params"]["key"] == "A-2"
|
||
failed = queue.get_command(conn, c["id"])
|
||
assert failed["status"] == "failed" and failed["error"] == "агент не отчитался после 3 выдач" and failed["finished_at"]
|
||
assert queue.MAX_DELIVERIES == 3
|
||
|
||
|
||
def test_redelivery_oldest_first(conn):
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||
queue.create_batch(conn, "y", "claude", [fields("A-2")])
|
||
rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall()
|
||
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-13T10:00:00+00:00', deliveries = 1 WHERE id = ?", (rows[1]["id"],))
|
||
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-13T11:00:00+00:00', deliveries = 1 WHERE id = ?", (rows[0]["id"],))
|
||
assert queue.claim(conn, "jira")["id"] == rows[1]["id"]
|
||
|
||
|
||
def test_redelivered_refresh_keeps_one_sync_run(conn):
|
||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
r = queue.claim(conn, "jira")
|
||
assert queue.claim(conn, "jira")["id"] == r["id"]
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1
|
||
|
||
|
||
def test_redelivery_does_not_repeat_backup(conn):
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||
labels = []
|
||
queue.claim(conn, "jira", backup=labels.append)
|
||
queue.claim(conn, "jira", backup=labels.append)
|
||
assert len(labels) == 1
|
||
|
||
|
||
def test_migrated_claimed_is_redelivered(conn):
|
||
"""Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2."""
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
|
||
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,))
|
||
assert queue.claim(conn, "jira")["id"] == cid
|
||
assert queue.get_command(conn, cid)["deliveries"] == 2
|
||
|
||
|
||
def test_report_for_queued_command_rejected(conn):
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
|
||
with pytest.raises(queue.NotClaimedError):
|
||
queue.report_result(conn, cid, "done")
|
||
assert queue.get_command(conn, cid)["status"] == "queued"
|
||
|
||
|
||
def test_late_report_after_force_cancel_keeps_issue_key(conn):
|
||
mirror.set_note(conn, "new:7", {"rest_text": "В работе"}, "import")
|
||
queue.create_batch(conn, "x", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
||
c = queue.claim(conn, "jira")
|
||
cancelled = queue.cancel_command(conn, c["id"], force=True)
|
||
late = queue.report_result(conn, c["id"], "done", result={"key": "A-20", "created": True}, jira_calls=3)
|
||
assert late["status"] == "cancelled" and late["result"] == {"key": "A-20", "created": True} and late["jira_calls"] == 3
|
||
assert late["finished_at"] == cancelled["finished_at"]
|
||
assert mirror.get_note(conn, "A-20")["rest_text"] == "В работе" and mirror.get_note(conn, "new:7") is None
|
||
again = queue.report_result(conn, c["id"], "done", result={"key": "A-21"})
|
||
assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет
|
||
|
||
|
||
def test_late_failed_report_after_force_cancel_changes_nothing(conn):
|
||
queue.create_batch(conn, "x", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
||
c = queue.claim(conn, "jira")
|
||
queue.cancel_command(conn, c["id"], force=True)
|
||
late = queue.report_result(conn, c["id"], "failed", error="HTTP 400")
|
||
assert late["status"] == "cancelled" and late["result"] is None and late["error"] is None
|
||
|
||
|
||
def _fail_by_limit(conn, cid):
|
||
for _ in range(queue.MAX_DELIVERIES - 1):
|
||
assert queue.claim(conn, "jira")["id"] == cid
|
||
queue.claim(conn, "jira") # четвёртый claim: cid → failed по пределу
|
||
assert queue.get_command(conn, cid)["status"] == "failed"
|
||
|
||
|
||
def test_error_is_trimmed_to_1500(conn):
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||
c = queue.claim(conn, "jira")
|
||
out = queue.report_result(conn, c["id"], "failed", error="е" * 5000)
|
||
assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500
|
||
|
||
|
||
def test_late_done_after_delivery_limit_keeps_issue_key(conn):
|
||
mirror.set_note(conn, "new:8", {"rest_text": "План"}, "import")
|
||
queue.create_batch(conn, "x", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
|
||
c = queue.claim(conn, "jira")
|
||
_fail_by_limit(conn, c["id"])
|
||
late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2)
|
||
assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True}
|
||
assert late["error"] == "агент не отчитался после 3 выдач"
|
||
assert mirror.get_note(conn, "A-30")["rest_text"] == "План" and mirror.get_note(conn, "new:8") is None
|
||
again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"})
|
||
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
|
||
|
||
|
||
def test_cancelled_batch_with_done_command_is_cancelled(conn):
|
||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")])
|
||
first = queue.claim(conn, "jira")
|
||
queue.report_result(conn, first["id"], "done", result={"changed": []})
|
||
second = queue.claim(conn, "jira")
|
||
queue.cancel_batch(conn, b["id"])
|
||
queue.report_result(conn, second["id"], "done", result={"changed": []})
|
||
view = queue.batch_view(conn, b["id"])
|
||
assert view["status"] == "cancelled"
|
||
assert view["counts"]["done"] == 2
|
||
|
||
|
||
def test_empty_full_refresh_keeps_neighbors(conn, add_project):
|
||
add_project(conn)
|
||
jql = jira_projects.base_jql(conn)
|
||
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
|
||
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
|
||
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
|
||
r = queue.claim(conn, "jira")
|
||
queue.report_result(conn, r["id"], "done") # загрузок состояния не было: 0 задач
|
||
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
|
||
|
||
|
||
def test_cancel_finished_batch_stays_done(conn):
|
||
b = queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||
while (c := queue.claim(conn, "jira")):
|
||
queue.report_result(conn, c["id"], "done", result={"changed": []})
|
||
view = queue.cancel_batch(conn, b["id"])
|
||
assert view["status"] == "done"
|
||
assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None
|
||
|
||
|
||
def test_redelivered_full_refresh_marks_neighbors_once(conn, add_project):
|
||
add_project(conn)
|
||
jql = jira_projects.base_jql(conn)
|
||
mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00")
|
||
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
|
||
r = queue.claim(conn, "jira")
|
||
refresh.record_state(conn, r["id"], 1, 0)
|
||
mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], [])
|
||
assert queue.claim(conn, "jira")["id"] == r["id"] # отчёт потерян — довыдача
|
||
assert conn.execute("SELECT deliveries FROM command WHERE id = ?", (r["id"],)).fetchone()["deliveries"] == 2
|
||
refresh.record_state(conn, r["id"], 1, 0)
|
||
mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], [])
|
||
queue.report_result(conn, r["id"], "done")
|
||
nb = {row["key"]: row["neighbor"] for row in conn.execute("SELECT key, neighbor FROM issue")}
|
||
assert nb == {"OLD": 1, "NEW": 0}
|
||
assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1
|
||
|
||
|
||
def test_redelivered_refresh_resets_counts(conn):
|
||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
r = queue.claim(conn, "jira")
|
||
refresh.record_state(conn, r["id"], 150, 40)
|
||
started = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["started_at"]
|
||
assert queue.claim(conn, "jira")["id"] == r["id"] # отчёт потерян — довыдача
|
||
refresh.record_state(conn, r["id"], 150, 40)
|
||
run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()
|
||
assert (run["issues_count"], run["links_count"]) == (150, 40)
|
||
assert run["started_at"] == started
|
||
|
||
|
||
def test_refresh_failed_by_limit_finishes_sync_run(conn):
|
||
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
r = queue.claim(conn, "jira")
|
||
for _ in range(queue.MAX_DELIVERIES):
|
||
queue.claim(conn, "jira")
|
||
assert queue.get_command(conn, r["id"])["status"] == "failed"
|
||
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
|
||
|
||
|
||
def test_cancel_batch_reports_still_claimed(conn):
|
||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||
c = queue.claim(conn, "jira")
|
||
view = queue.cancel_batch(conn, b["id"])
|
||
assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}]
|
||
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||
|
||
|
||
def test_dependent_skipped_when_creation_cancelled(conn):
|
||
"""O-10 #16, решение 10, спека «Создание отменено»: зависимая команда — «создание задачи new:N отменено»."""
|
||
b = queue.create_batch(conn, "x", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:901", "issue_type": "task", "summary": "Отменённая"}},
|
||
{"type": "ensure_comment", "params": {"key": "new:901", "marker": "m", "body": "b"}}])
|
||
c = queue.claim(conn, "jira")
|
||
queue.cancel_command(conn, c["id"], force=True)
|
||
assert queue.claim(conn, "jira") is None # комментарий и хвостовой refresh пропущены
|
||
cmds = queue.batch_view(conn, b["id"])["commands"]
|
||
assert [x["status"] for x in cmds] == ["cancelled", "skipped", "skipped"]
|
||
assert cmds[1]["error"] == "создание задачи new:901 отменено"
|
||
|
||
|
||
def test_dependent_on_failed_and_cancelled_names_cancelled(conn):
|
||
"""Review Focus 4: два временных ключа — создатель первого failed, второго cancelled: текст по отменённому."""
|
||
b = queue.create_batch(conn, "x", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Упавшая"}},
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Отменённая"}},
|
||
{"type": "ensure_link", "params": {"source": "new:1", "target": "new:2", "link_type": "связанные"}}])
|
||
first = queue.claim(conn, "jira")
|
||
queue.report_result(conn, first["id"], "failed", error="HTTP 400")
|
||
second = queue.claim(conn, "jira")
|
||
queue.cancel_command(conn, second["id"], force=True)
|
||
queue.claim(conn, "jira")
|
||
link = queue.batch_view(conn, b["id"])["commands"][2]
|
||
assert link["status"] == "skipped" and link["error"] == "создание задачи new:2 отменено"
|
||
|
||
|
||
def test_reconcile_confirms_fields_with_crlf(conn):
|
||
"""O-9 #16, решение 11, спека «Описание с CRLF в зеркале»: потерянный отчёт закрывается по состоянию."""
|
||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_fields", "params": {"key": "A-1", "description": "a\nb"}}])
|
||
c = queue.claim(conn, "jira")
|
||
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], [])
|
||
assert queue.reconcile(conn) == [c["id"]]
|
||
assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state"
|
||
|
||
|
||
def test_delivery_limit_of_write_command_keeps_open_sync_run(conn):
|
||
"""T5.2 #16: предел выдач у не-refresh команды не закрывает журнал чужой загрузки (finished_at — только для refresh)."""
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||
c = queue.claim(conn, "jira")
|
||
r = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-9"]}}])
|
||
rid = queue.batch_view(conn, r["id"])["commands"][0]["id"]
|
||
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2099-01-01T00:00:00+00:00', deliveries = 1 "
|
||
"WHERE id = ?", (rid,))
|
||
conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, 'keys', 'A-9', ?)", (db.now(), rid))
|
||
for _ in range(queue.MAX_DELIVERIES - 1):
|
||
assert queue.claim(conn, "jira")["id"] == c["id"]
|
||
assert queue.claim(conn, "jira")["id"] == rid # c — failed по пределу, довыдаётся refresh
|
||
assert queue.get_command(conn, c["id"])["status"] == "failed"
|
||
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (rid,)).fetchone()["finished_at"] is None
|
||
|
||
|
||
def bug(temp="new:1", summary="ОСВ 60 не распознана"):
|
||
return {"type": "ensure_issue", "params": {"temp_key": temp, "issue_type": "bug", "summary": summary,
|
||
"labels": ["AWPCA"]}}
|
||
|
||
|
||
SHA = hashlib.sha256(b"abc").hexdigest()
|
||
|
||
|
||
@pytest.fixture
|
||
def store(tmp_path):
|
||
"""Каталог хранилища с файлом «abc»."""
|
||
root = tmp_path / "files"
|
||
files.put(root, b"abc")
|
||
return root
|
||
|
||
|
||
def attach(key="A-1", file=SHA, filename="ОСВ 60.xlsx"):
|
||
return {"type": "ensure_attachment", "params": {"key": key, "file": file, "filename": filename}}
|
||
|
||
|
||
def test_bug_without_setting_rejected(conn):
|
||
"""Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан."""
|
||
settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}})
|
||
with pytest.raises(CommandError) as e:
|
||
queue.create_batch(conn, "ошибка", "claude", [fields("A-1"), bug()])
|
||
assert str(e.value) == "команда 2: нет настройки issue_types.bug"
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
||
|
||
|
||
@pytest.mark.parametrize("value", [{"name": None, "id": None}, {"name": "", "id": ""}, "Ошибка", None])
|
||
def test_bug_with_empty_setting_rejected(conn, value):
|
||
settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": None}, "bug": value})
|
||
with pytest.raises(CommandError, match="нет настройки issue_types.bug"):
|
||
queue.create_batch(conn, "ошибка", "claude", [bug()])
|
||
|
||
|
||
@pytest.mark.parametrize("value", [{"name": "Ошибка", "id": None}, {"name": None, "id": "10102"}])
|
||
def test_bug_with_setting_accepted(conn, value):
|
||
settings.set_(conn, "issue_types", {"bug": value})
|
||
b = queue.create_batch(conn, "ошибка", "claude", [bug()])
|
||
assert b["count"] == 2 # ensure_issue + хвостовой refresh
|
||
|
||
|
||
def test_attachment_without_file_rejected(conn, store):
|
||
"""Спека commands «Вложение без загруженного файла»: 422, пакет не создан."""
|
||
missing = "0" * 64
|
||
with pytest.raises(CommandError) as e:
|
||
queue.create_batch(conn, "вложение", "claude", [attach(file=missing)], files_dir=store)
|
||
assert str(e.value) == f"команда 1: файл {missing} не загружен: POST /files"
|
||
with pytest.raises(CommandError, match=f"файл {SHA} не загружен"):
|
||
queue.create_batch(conn, "вложение", "claude", [attach()]) # хранилище не передано
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
||
|
||
|
||
@pytest.mark.parametrize("params, msg", [
|
||
({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
|
||
({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
|
||
({"filename": " "}, "команда 1: filename пустой"),
|
||
({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
|
||
({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
|
||
({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
|
||
({"filename": "ОСВ\n60.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
|
||
({"filename": "ОСВ\r.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
|
||
({"filename": "ОСВ\x7f.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
|
||
])
|
||
def test_attachment_params_rejected(conn, store, params, msg):
|
||
cmd = attach()
|
||
cmd["params"].update(params)
|
||
with pytest.raises(CommandError) as e:
|
||
queue.create_batch(conn, "вложение", "claude", [cmd], files_dir=store)
|
||
assert str(e.value) == msg
|
||
|
||
|
||
def test_bug_with_attachment_one_batch(conn, store, add_project):
|
||
"""Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size."""
|
||
add_project(conn)
|
||
settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}})
|
||
b = queue.create_batch(conn, "ошибка", "claude", [
|
||
bug(), {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, attach(key="new:1")],
|
||
files_dir=store)
|
||
assert b["count"] == 4 and b["estimate_seconds"] == 120 + 30 + 30 + 2
|
||
c1 = queue.claim(conn, "jira", files_dir=store)
|
||
assert c1["type"] == "ensure_issue"
|
||
queue.report_result(conn, c1["id"], "done", result={"key": "AURORA-1650"})
|
||
c2 = queue.claim(conn, "jira", files_dir=store)
|
||
assert c2["type"] == "ensure_sprint"
|
||
queue.report_result(conn, c2["id"], "done")
|
||
c3 = queue.claim(conn, "jira", files_dir=store)
|
||
assert c3["type"] == "ensure_attachment" and c3["pause_seconds"] == 30
|
||
assert c3["params"] == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx", "size": 3}
|
||
again = queue.claim(conn, "jira", files_dir=store) # довыдача — тоже с size
|
||
assert again["id"] == c3["id"] and again["params"]["size"] == 3
|
||
stored = queue.get_command(conn, c3["id"])["params"]
|
||
assert stored == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx"} # в базе без size
|
||
|
||
|
||
def test_attachment_size_null_when_file_gone(conn, store):
|
||
queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store)
|
||
(store / SHA).unlink()
|
||
c = queue.claim(conn, "jira", files_dir=store)
|
||
assert c["type"] == "ensure_attachment" and c["params"]["size"] is None
|
||
assert queue.claim(conn, "jira")["params"]["size"] is None # без хранилища — тоже null, не исключение
|
||
assert queue.deliver_params(conn, queue.get_command(conn, c["id"]), store)["size"] is None
|
||
|
||
|
||
def test_attachment_retry_passes_validation(conn, store):
|
||
queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store)
|
||
c = queue.claim(conn, "jira", files_dir=store)
|
||
queue.report_result(conn, c["id"], "failed", error="HTTP 413")
|
||
r = queue.retry_command(conn, c["id"], "claude", files_dir=store)
|
||
assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "file": SHA, "filename": "ОСВ 60.xlsx"}
|
||
(store / SHA).unlink() # убран уборкой
|
||
with pytest.raises(CommandError, match=f"команда 1: файл {SHA} не загружен: POST /files"):
|
||
queue.retry_command(conn, c["id"], "claude", files_dir=store)
|
||
|
||
|
||
def test_attachment_not_confirmed_by_state(conn, store):
|
||
mirror.apply_state(conn, [{"key": "A-1", "summary": "Т", "labels": []}], [])
|
||
queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store)
|
||
c = queue.claim(conn, "jira", files_dir=store)
|
||
assert queue.reconcile(conn) == [] and queue.get_command(conn, c["id"])["status"] == "claimed"
|
||
|
||
|
||
# --- пакет проекта (#61, Task 5) ----------------------------------------------
|
||
|
||
S = "STOCKS_DRRK"
|
||
|
||
|
||
def two(conn, add_project):
|
||
"""Проекты AWPCA (доска 4216, scrum) и STOCKS_DRRK (без доски); AURORA-1538 — AWPCA, AURORA-1 — STOCKS_DRRK."""
|
||
add_project(conn)
|
||
add_project(conn, S, board_id=None)
|
||
mirror.apply_state(conn, [{"key": "AURORA-1538", "summary": "А", "labels": ["AWPCA"]},
|
||
{"key": "AURORA-1", "summary": "С", "labels": [S]}], [])
|
||
|
||
|
||
def new_issue(labels=None, temp="new:1"):
|
||
p = {"temp_key": temp, "issue_type": "task", "summary": "Сверка остатков"}
|
||
return {"type": "ensure_issue", "params": p if labels is None else {**p, "labels": labels}}
|
||
|
||
|
||
def test_project_label_appended(conn, add_project): # сценарий «Метка проекта дописана»
|
||
two(conn, add_project)
|
||
src = new_issue(["kind-dev"])
|
||
b = queue.create_batch(conn, "x", "claude", [src], project=S)
|
||
view = queue.batch_view(conn, b["id"])
|
||
assert view["project"] == S and view["commands"][0]["params"]["labels"] == ["kind-dev", S]
|
||
assert src["params"]["labels"] == ["kind-dev"] # входные параметры не мутируются
|
||
b = queue.create_batch(conn, "y", "claude", [new_issue(temp="new:2")], project=S)
|
||
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S]
|
||
|
||
|
||
def test_project_label_case_variant_replaced(conn, add_project): # своя метка другим регистром заменяется на P
|
||
two(conn, add_project)
|
||
b = queue.create_batch(conn, "x", "claude", [new_issue(["stocks_drrk", "kind-dev"])], project=S)
|
||
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S, "kind-dev"]
|
||
b = queue.create_batch(conn, "y", "claude", [new_issue(["Stocks_Drrk", S], temp="new:2")], project=S)
|
||
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] # без повтора P
|
||
|
||
|
||
def test_project_ensure_fields_labels_add_case_normalized(conn, add_project): # M-2
|
||
two(conn, add_project)
|
||
b = queue.create_batch(conn, "x", "claude", [fields("AURORA-1", labels_add=["stocks_drrk", "kind-dev", S])], project=S)
|
||
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels_add"] == [S, "kind-dev"]
|
||
|
||
|
||
@pytest.mark.parametrize("cmd, msg", [
|
||
(new_issue(["awpca"]), "команда 1: метка другого проекта: awpca"), # «Чужая метка проекта»
|
||
(fields("AURORA-1538"), "команда 1: AURORA-1538 не в проекте STOCKS_DRRK"), # «Задача другого проекта»
|
||
(fields("AURORA-1", labels_remove=["stocks_drrk"]), "команда 1: снять метку своего проекта STOCKS_DRRK нельзя"),
|
||
(fields("AURORA-1", labels_add=["AWPCA"]), "команда 1: метка другого проекта: AWPCA"),
|
||
({"type": "ensure_comment", "params": {"key": "AURORA-404", "marker": "m", "body": "b"}}, "AURORA-404 не в проекте"),
|
||
({"type": "ensure_status", "params": {"key": "AURORA-1538", "target": "Готово"}}, "AURORA-1538 не в проекте"),
|
||
({"type": "ensure_link", "params": {"source": "AURORA-1538", "target": "AURORA-404", "link_type": "связанные"}},
|
||
"команда 1: связь AURORA-1538 → AURORA-404 не касается проекта STOCKS_DRRK"),
|
||
])
|
||
def test_project_batch_rejected(conn, add_project, cmd, msg):
|
||
two(conn, add_project)
|
||
with pytest.raises(CommandError, match=msg):
|
||
queue.create_batch(conn, "x", "claude", [cmd], project=S)
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
||
|
||
|
||
def test_project_batch_accepted(conn, add_project):
|
||
two(conn, add_project)
|
||
link = {"type": "ensure_link", "params": {"source": "AURORA-1", "target": "AURORA-1538", "link_type": "связанные"}}
|
||
queue.create_batch(conn, "связь", "claude", [link], project=S) # «Связь с соседом»
|
||
queue.create_batch(conn, "новая", "claude", [new_issue(), {"type": "ensure_comment",
|
||
"params": {"key": "new:1", "marker": "m", "body": "b"}}], project=S) # временный ключ — свой
|
||
queue.create_batch(conn, "jql", "claude", [{"type": "refresh", "params": {"jql": "key = AURORA-1538"}}], project=S)
|
||
|
||
|
||
def test_unknown_project_batch(conn):
|
||
with pytest.raises(CommandError, match="проекта NOPE нет"):
|
||
queue.create_batch(conn, "x", "claude", [fields("AURORA-1")], project="NOPE")
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
||
|
||
|
||
def test_batch_without_project_unchanged(conn, add_project):
|
||
two(conn, add_project)
|
||
b = queue.create_batch(conn, "x", "claude", [new_issue(["awpca"]), fields("AURORA-1538")])
|
||
view = queue.batch_view(conn, b["id"])
|
||
assert view["project"] is None and view["commands"][0]["params"]["labels"] == ["awpca"]
|
||
|
||
|
||
def test_list_batches_by_project(conn, add_project): # сценарий «Пакеты проекта»
|
||
two(conn, add_project)
|
||
a = queue.create_batch(conn, "a", "claude", [fields("AURORA-1")], project=S)
|
||
queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")])
|
||
assert [b["id"] for b in queue.list_batches(conn, project=S)] == [a["id"]]
|
||
assert len(queue.list_batches(conn)) == 2
|
||
|
||
|
||
def test_retry_project_rules(conn, add_project): # сценарий «Повтор чужой команды»
|
||
two(conn, add_project)
|
||
own = queue.create_batch(conn, "a", "claude", [fields("AURORA-1538")], project="AWPCA")
|
||
loose = queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")])
|
||
cid = lambda b: queue.batch_view(conn, b["id"])["commands"][0]["id"]
|
||
with pytest.raises(CommandError, match=f"команда #{cid(own)} не из проекта STOCKS_DRRK"):
|
||
queue.retry_command(conn, cid(own), "claude", project=S)
|
||
with pytest.raises(CommandError, match="не из проекта AWPCA"):
|
||
queue.retry_command(conn, cid(loose), "claude", project="AWPCA")
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 2
|
||
r = queue.retry_command(conn, cid(own), "claude") # без project — проект исходного
|
||
assert queue.batch_view(conn, r["id"])["project"] == "AWPCA"
|
||
assert queue.batch_view(conn, queue.retry_command(conn, cid(loose), "claude")["id"])["project"] is None
|
||
ok = queue.retry_command(conn, cid(own), "claude", project="AWPCA")
|
||
assert queue.batch_view(conn, ok["id"])["project"] == "AWPCA"
|
||
|
||
|
||
def test_retry_inherits_project_checks(conn, add_project): # Review Focus 5: повтор без project наследует проверки
|
||
two(conn, add_project)
|
||
own = queue.create_batch(conn, "a", "claude", [fields("AURORA-1")], project=S)
|
||
cid = queue.batch_view(conn, own["id"])["commands"][0]["id"]
|
||
conn.execute("UPDATE issue SET labels = '[\"AWPCA\"]' WHERE key = 'AURORA-1'") # задача ушла из проекта
|
||
before = conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"]
|
||
with pytest.raises(CommandError, match="AURORA-1 не в проекте STOCKS_DRRK"):
|
||
queue.retry_command(conn, cid, "claude")
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == before
|
||
|
||
|
||
# --- доски: ensure_sprint.board_id, refresh.boards, типы досок (#61, Task 6) -----------------------------------------
|
||
|
||
def sprint(key, name="Спринт 2"):
|
||
return {"type": "ensure_sprint", "params": {"key": key, "sprint": name}}
|
||
|
||
|
||
def test_sprint_gets_project_board(conn, add_project): # сценарий «Спринт задачи проекта»
|
||
add_project(conn)
|
||
mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], [])
|
||
b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) # и без project пакета
|
||
assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"key": "AURORA-1538", "sprint": "Спринт 2", "board_id": 4216}
|
||
|
||
|
||
@pytest.mark.parametrize("setup, key, msg", [
|
||
(dict(board_id=5120, board_type="kanban"), "AURORA-1", "у проекта STOCKS_DRRK канбан-доска, спринтов нет"),
|
||
(dict(board_id=5120, board_type=None), "AURORA-1", "тип доски проекта STOCKS_DRRK ещё не известен"),
|
||
(dict(board_id=None, board_type=None), "AURORA-1", "у проекта STOCKS_DRRK нет доски Jira"),
|
||
(dict(board_id=5120, board_type="scrum"), "AURORA-77", "AURORA-77 не входит ни в один проект"),
|
||
(dict(board_id=5120, board_type="scrum"), "AURORA-404", "AURORA-404 не входит ни в один проект"), # нет в зеркале
|
||
(dict(board_id=5120, board_type="scrum"), "AURORA-12", "с разными досками"),
|
||
])
|
||
def test_sprint_board_rejected(conn, add_project, setup, key, msg):
|
||
add_project(conn) # AWPCA 4216 scrum
|
||
add_project(conn, "STOCKS_DRRK", **setup)
|
||
mirror.apply_state(conn, [{"key": "AURORA-1", "labels": ["STOCKS_DRRK"]}, {"key": "AURORA-77", "labels": ["kind-dev"]},
|
||
{"key": "AURORA-12", "labels": ["AWPCA", "STOCKS_DRRK"]}], [])
|
||
with pytest.raises(CommandError, match=msg):
|
||
queue.create_batch(conn, "x", "claude", [sprint(key)])
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
||
|
||
|
||
def test_sprint_rejected_message_has_command_number(conn, add_project):
|
||
add_project(conn, board_id=5120, board_type="kanban")
|
||
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], [])
|
||
with pytest.raises(CommandError) as e:
|
||
queue.create_batch(conn, "x", "claude", [fields("A-1"), sprint("A-1")])
|
||
assert str(e.value) == "команда 2: у проекта AWPCA канбан-доска, спринтов нет"
|
||
|
||
|
||
def test_backlog_on_kanban_rejected(conn, add_project):
|
||
add_project(conn, board_id=5120, board_type="kanban")
|
||
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], [])
|
||
with pytest.raises(CommandError, match="канбан-доска"):
|
||
queue.create_batch(conn, "x", "claude", [sprint("A-1", "backlog")])
|
||
|
||
|
||
def test_sprint_two_projects_same_board(conn, add_project):
|
||
add_project(conn)
|
||
add_project(conn, "C", board_id=4216)
|
||
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA", "C"]}], [])
|
||
b = queue.create_batch(conn, "x", "claude", [sprint("A-1")])
|
||
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["board_id"] == 4216
|
||
|
||
|
||
def test_sprint_for_new_issue_uses_batch_labels(conn, add_project):
|
||
add_project(conn)
|
||
add_project(conn, "C", board_id=5120)
|
||
b = queue.create_batch(conn, "x", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, sprint("new:1")],
|
||
project="AWPCA") # метка проекта дописана Task 5
|
||
assert queue.batch_view(conn, b["id"])["commands"][1]["params"]["board_id"] == 4216
|
||
b = queue.create_batch(conn, "x", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Н2", "labels": ["C"]}},
|
||
sprint("new:2")]) # без проекта — метки самой команды
|
||
assert queue.batch_view(conn, b["id"])["commands"][1]["params"]["board_id"] == 5120
|
||
|
||
|
||
def test_sprint_for_new_issue_without_project_labels_rejected(conn, add_project):
|
||
add_project(conn)
|
||
mirror.apply_state(conn, [{"key": "new:1", "labels": ["AWPCA"]}], []) # зеркало не должно подменять метки пакета
|
||
with pytest.raises(CommandError, match="не входит ни в один проект"):
|
||
queue.create_batch(conn, "x", "claude", [
|
||
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, sprint("new:1")])
|
||
|
||
|
||
def test_client_board_id_rejected(conn, add_project):
|
||
add_project(conn)
|
||
with pytest.raises(CommandError, match="лишние параметры board_id"):
|
||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "С", "board_id": 1}}])
|
||
|
||
|
||
def test_input_params_not_mutated(conn, add_project):
|
||
add_project(conn)
|
||
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], [])
|
||
cmds = [sprint("A-1")]
|
||
queue.create_batch(conn, "x", "claude", cmds)
|
||
assert cmds == [sprint("A-1")]
|
||
|
||
|
||
def test_retry_sprint_recomputes_board(conn, add_project): # сценарий «Повтор ensure_sprint»
|
||
add_project(conn)
|
||
mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], [])
|
||
b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")])
|
||
cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
|
||
conn.execute("UPDATE jira_project SET board_id = 5120, board_type = 'scrum'")
|
||
r = queue.retry_command(conn, cid, "claude")
|
||
assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120
|
||
|
||
|
||
def test_retry_premigration_sprint_without_board(conn, add_project): # Review Focus 5
|
||
add_project(conn)
|
||
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], [])
|
||
cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES ('старый', 'claude', ?, 1, NULL)",
|
||
(db.now(),))
|
||
cid = conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (?, 1, 'ensure_sprint', ?)",
|
||
(cur.lastrowid, '{"key": "A-1", "sprint": "Спринт 2"}')).lastrowid
|
||
r = queue.retry_command(conn, cid, "claude")
|
||
assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "sprint": "Спринт 2", "board_id": 4216}
|
||
|
||
|
||
def meta(name="AWPCA_Sprint_4"):
|
||
return {"type": "ensure_sprint_meta", "params": {"sprint": name, "start_date": "2026-10-28", "end_date": "2026-11-11"}}
|
||
def close(name="AWPCA_Sprint_3_Corrections", to="AWPCA_Sprint_4"):
|
||
return {"type": "ensure_sprint_state", "params": {"sprint": name, "state": "closed", "move_open_to": to}}
|
||
|
||
def test_sprint_meta_gets_batch_project_board(conn, add_project):
|
||
add_project(conn)
|
||
b = queue.create_batch(conn, "x", "claude", [meta(), close()], project="AWPCA")
|
||
cmds = queue.batch_view(conn, b["id"])["commands"]
|
||
assert cmds[0]["params"]["board_id"] == 4216 and cmds[1]["params"]["board_id"] == 4216
|
||
|
||
def test_sprint_meta_without_project_rejected(conn, add_project): # сценарий «Спринт без проекта пакета»
|
||
add_project(conn)
|
||
with pytest.raises(CommandError) as e:
|
||
queue.create_batch(conn, "x", "claude", [meta()])
|
||
assert str(e.value) == "команда 1 (ensure_sprint_meta): команды спринта — только в пакете проекта"
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
||
|
||
@pytest.mark.parametrize("setup, msg", [
|
||
(dict(board_id=5120, board_type="kanban"), "команда 1: у проекта AWPCA канбан-доска, спринтов нет"),
|
||
(dict(board_id=5120, board_type=None), "команда 1: тип доски проекта AWPCA ещё не известен — дождитесь обновления из Jira"),
|
||
(dict(board_id=None, board_type=None), "команда 1: у проекта AWPCA нет доски Jira"),
|
||
])
|
||
def test_sprint_state_board_rejected(conn, add_project, setup, msg):
|
||
add_project(conn, **setup)
|
||
with pytest.raises(CommandError) as e:
|
||
queue.create_batch(conn, "x", "claude", [close()], project="AWPCA")
|
||
assert str(e.value) == msg
|
||
|
||
def test_rank_belongs_to_project_by_key(conn, add_project): # design #19
|
||
add_project(conn); add_project(conn, "C", board_id=5120)
|
||
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["C"]}], [])
|
||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA")
|
||
with pytest.raises(CommandError, match="команда 1: Y не в проекте AWPCA"):
|
||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "Y", "after": "X"}}], project="AWPCA")
|
||
|
||
def test_retry_sprint_meta_recomputes_board(conn, add_project):
|
||
add_project(conn)
|
||
b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA")
|
||
cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
|
||
conn.execute("UPDATE jira_project SET board_id = 5120")
|
||
r = queue.retry_command(conn, cid, "claude")
|
||
assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120
|
||
|
||
def test_retry_sprint_state_after_board_became_kanban(conn, add_project): # Review Focus 4
|
||
add_project(conn)
|
||
b = queue.create_batch(conn, "x", "claude", [close()], project="AWPCA")
|
||
cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
|
||
conn.execute("UPDATE jira_project SET board_type = 'kanban'")
|
||
with pytest.raises(CommandError, match="канбан-доска, спринтов нет"):
|
||
queue.retry_command(conn, cid, "claude")
|
||
|
||
|
||
def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1
|
||
add_project(conn)
|
||
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None)
|
||
add_project(conn, "C", board_id=4216)
|
||
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": "key = A-1"}}])
|
||
got = queue.claim(conn, "jira")
|
||
assert got["params"]["boards"] == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}]
|
||
assert "boards" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
|
||
assert queue.claim(conn, "jira")["params"]["boards"] == got["params"]["boards"] # довыдача — тоже с boards
|
||
|
||
|
||
def test_refresh_boards_empty_list(conn, add_project):
|
||
add_project(conn, board_id=None, board_type=None)
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
assert queue.claim(conn, "jira")["params"]["boards"] == []
|
||
|
||
|
||
@pytest.mark.parametrize("result, expected", [
|
||
({"boards": {"5120": "kanban"}}, "kanban"), # ключ — строка, project.board_id — int
|
||
({"issues": 3}, None), # агент v11 / v12 без запроса типов: ключа нет
|
||
({"boards": {"5120": None}}, None), # доска не найдена — тип не меняется
|
||
({"boards": {"abc": "scrum"}}, None),
|
||
({"boards": {"5120": "sprint"}}, None), # мусорный тип
|
||
({"boards": ["5120", "kanban"]}, None), # boards не словарь
|
||
({"boards": "kanban"}, None),
|
||
(None, None),
|
||
])
|
||
def test_refresh_result_board_types(conn, add_project, result, expected):
|
||
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None)
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
c = queue.claim(conn, "jira")
|
||
queue.report_result(conn, c["id"], "done", result=result)
|
||
assert api.project_rows(conn, ["STOCKS_DRRK"])[0]["board_type"] == expected
|
||
|
||
|
||
def test_refresh_result_board_type_changes_scrum_to_kanban(conn, add_project): # доска в Jira стала канбаном
|
||
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type="scrum")
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
c = queue.claim(conn, "jira")
|
||
queue.report_result(conn, c["id"], "done", result={"boards": {"5120": "kanban"}})
|
||
assert api.project_rows(conn, ["STOCKS_DRRK"])[0]["board_type"] == "kanban"
|
||
|
||
|
||
def test_refresh_failed_result_records_board_types(conn, add_project): # R-3 / T6.1: тип доски — факт о доске, а не часть выборки
|
||
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None)
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
c = queue.claim(conn, "jira")
|
||
queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e")
|
||
assert api.project_rows(conn, ["STOCKS_DRRK"])[0]["board_type"] == "kanban"
|
||
|
||
|
||
# --- журнал изменений: history в выдаче refresh, sync_run.history_at (#70, Task 3) ----------------------------------
|
||
|
||
def _full_run(conn, result, started_at=None, status="done"):
|
||
"""Полный refresh: поставить, выдать, отчитаться; started_at — подменить время начала прогона."""
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||
c = queue.claim(conn, "jira")
|
||
if started_at:
|
||
conn.execute("UPDATE sync_run SET started_at = ? WHERE command_id = ?", (started_at, c["id"]))
|
||
queue.report_result(conn, c["id"], status, result=result)
|
||
return c
|
||
|
||
|
||
def test_first_full_refresh_gets_backfill(conn, add_project): # сценарий «Первый refresh после выкатки»
|
||
add_project(conn)
|
||
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||
got = queue.claim(conn, "jira")
|
||
assert got["params"]["history"] == {
|
||
"jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'}
|
||
assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
|
||
|
||
|
||
def test_regular_refresh_gets_since(conn, add_project): # «Регулярный refresh» и «Журнал не дочитан»
|
||
add_project(conn)
|
||
_full_run(conn, {"history": {"issues": 1, "changes": 2, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
|
||
_full_run(conn, {"history": {"error": "HTTP 500"}}, "2026-10-22T10:00:00+00:00")
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||
assert queue.claim(conn, "jira")["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
|
||
|
||
|
||
def test_new_project_gets_since_and_backfill(conn, add_project): # «Новый проект»
|
||
add_project(conn)
|
||
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
|
||
add_project(conn, "STOCKS_DRRK", board_id=5120)
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||
h = queue.claim(conn, "jira")["params"]["history"]
|
||
assert h["since"] == "2026-10-22T08:45:00Z"
|
||
assert h["jql"] == "(" + jira_projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done"
|
||
assert "STOCKS_DRRK" in h["jql"]
|
||
|
||
|
||
def test_tail_refresh_since_and_no_mark(conn, add_project): # «Хвост пакета»
|
||
add_project(conn)
|
||
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
c = queue.claim(conn, "jira")
|
||
assert c["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
|
||
queue.report_result(conn, c["id"], "done", result={"history": {"issues": 0, "changes": 0, "incomplete": []}})
|
||
assert conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone()["history_at"] is None
|
||
|
||
|
||
def test_tail_refresh_without_mark_gets_nothing(conn, add_project): # «Хвост пакета до первой отметки»
|
||
add_project(conn)
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
assert "history" not in queue.claim(conn, "jira")["params"]
|
||
|
||
|
||
def test_no_projects_no_history(conn):
|
||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||
assert "history" not in queue.claim(conn, "jira")["params"]
|
||
|
||
|
||
def test_redelivery_recomputes_history(conn, add_project): # Review Focus 3
|
||
add_project(conn)
|
||
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||
first = queue.claim(conn, "jira")
|
||
again = queue.claim(conn, "jira") # отчёта нет — довыдача
|
||
assert again["id"] == first["id"] and again["params"]["history"] == first["params"]["history"]
|
||
assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
|
||
|
||
|
||
@pytest.mark.parametrize("result, status, marked", [
|
||
({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "done", True),
|
||
({"history": {"issues": 1, "changes": 2, "incomplete": ["X"], "backfill_error": "HTTP 400"}}, "done", True),
|
||
({"history": {"issues": 0, "changes": 0, "error": ""}}, "done", True),
|
||
({"history": {"error": "HTTP 500"}}, "done", False),
|
||
({"issues": 3}, "done", False), # агент v12
|
||
({"history": "мусор"}, "done", False),
|
||
(None, "done", False),
|
||
({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "failed", False),
|
||
])
|
||
def test_report_sets_history_at(conn, add_project, result, status, marked):
|
||
add_project(conn)
|
||
c = _full_run(conn, result, status=status)
|
||
run = conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone()
|
||
assert (run["history_at"] is not None) == marked
|
||
|
||
|
||
# --- fetch_attachment (#83) ---------------------------------------------------
|
||
|
||
import threading
|
||
|
||
FSHA = "cd" * 32
|
||
|
||
|
||
@pytest.fixture
|
||
def fconn(conn):
|
||
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
|
||
{"id": "31337", "filename": "ОСВ 60.xlsx", "size": 3, "created": "2026-10-20T10:00:00.000+0300"},
|
||
{"id": "31338", "filename": "big.bin", "size": 25_000_000, "created": "2026-10-20T10:00:00.000+0300"},
|
||
{"id": "31339", "filename": "unk.bin", "created": "2026-10-20T10:00:00.000+0300"}]},
|
||
{"key": "B-1", "summary": "т", "labels": []}], [])
|
||
return conn
|
||
|
||
|
||
def fetch(att="31337", key="A-1"):
|
||
return {"type": "fetch_attachment", "params": {"key": key, "attachment_id": att}}
|
||
|
||
|
||
def test_fetch_batch_no_tail_refresh(fconn):
|
||
b = queue.create_batch(fconn, "x", "claude", [fetch()])
|
||
assert b["count"] == 1
|
||
assert [c["type"] for c in queue.batch_view(fconn, b["id"])["commands"]] == ["fetch_attachment"]
|
||
assert b["estimate_seconds"] == 2
|
||
|
||
|
||
def test_fetch_checks(fconn):
|
||
with pytest.raises(CommandError):
|
||
queue.create_batch(fconn, "x", "claude", [fetch("1a")])
|
||
with pytest.raises(CommandError) as e:
|
||
queue.create_batch(fconn, "x", "claude", [fetch(key="B-1")])
|
||
assert str(e.value) == "у B-1 нет вложения #31337 в зеркале — обновите задачу"
|
||
with pytest.raises(jira_fetch.TooLarge) as e:
|
||
queue.create_batch(fconn, "x", "claude", [fetch("31338")])
|
||
assert str(e.value) == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira"
|
||
assert queue.create_batch(fconn, "x", "claude", [fetch("31339")])["count"] == 1 # size NULL — ставится
|
||
|
||
|
||
def test_fetch_size_limit_boundary(fconn):
|
||
"""T3.4: ровно MAX_SIZE принимается, MAX_SIZE+1 — TooLarge."""
|
||
mirror.apply_state(fconn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
|
||
{"id": "41", "filename": "eq.bin", "size": files.MAX_SIZE, "created": "2026-10-20T10:00:00.000+0300"},
|
||
{"id": "42", "filename": "over.bin", "size": files.MAX_SIZE + 1, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
|
||
assert jira_fetch.request_fetch(fconn, None, "41", "claude")["state"] == "fetching"
|
||
with pytest.raises(jira_fetch.TooLarge):
|
||
jira_fetch.request_fetch(fconn, None, "42", "claude")
|
||
|
||
|
||
def test_fetch_report_done_non_dict_result_fails(fconn, tmp_path):
|
||
"""T3.4: done с result не-словарём — failed, вложение не отмечено скачанным."""
|
||
queue.create_batch(fconn, "x", "claude", [fetch()])
|
||
c = queue.claim(fconn, "jira")
|
||
r = queue.report_result(fconn, c["id"], "done", result="ok", files_dir=tmp_path)
|
||
assert r["status"] == "failed"
|
||
assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0
|
||
|
||
|
||
def test_fetch_no_backup(fconn):
|
||
queue.create_batch(fconn, "x", "claude", [fetch()])
|
||
calls = []
|
||
cmd = queue.claim(fconn, "jira", backup=calls.append)
|
||
assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2 and calls == []
|
||
|
||
|
||
def test_fetch_project_batch_no_checks(fconn, add_project):
|
||
add_project(fconn, "AWPCA")
|
||
b = queue.create_batch(fconn, "x", "claude", [fetch(key="A-1")], project="AWPCA") # A-1 не в проекте
|
||
assert b["count"] == 1
|
||
|
||
|
||
def test_request_fetch_once(open_db, tmp_path):
|
||
path = tmp_path / "hub.sqlite"
|
||
c1 = open_db(path)
|
||
settings.ensure_defaults(c1)
|
||
mirror.apply_state(c1, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
|
||
{"id": "31337", "filename": "a.png", "size": 3, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
|
||
c2 = open_db(path)
|
||
res, errs = [], []
|
||
barrier = threading.Barrier(2)
|
||
|
||
def run(c):
|
||
try:
|
||
barrier.wait()
|
||
res.append(jira_fetch.request_fetch(c, tmp_path / "files", "31337", "x"))
|
||
except Exception as e: # noqa: BLE001
|
||
errs.append(e)
|
||
|
||
ts = [threading.Thread(target=run, args=(c,)) for c in (c1, c2)]
|
||
[t.start() for t in ts]
|
||
[t.join() for t in ts]
|
||
assert not errs
|
||
assert res[0]["command_id"] == res[1]["command_id"] and res[0]["batch_id"] == res[1]["batch_id"]
|
||
assert {r["state"] for r in res} <= {"fetching"}
|
||
assert c1.execute("SELECT COUNT(*) AS n FROM command WHERE type = 'fetch_attachment'").fetchone()["n"] == 1
|
||
|
||
|
||
def put_file(tmp_path, data=b"abc"):
|
||
root = tmp_path / "files"
|
||
sha, _ = files.put(root, data)
|
||
return root, sha
|
||
|
||
|
||
def test_request_fetch_ready(fconn, tmp_path):
|
||
root, sha = put_file(tmp_path)
|
||
fconn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('31337', ?, 3, ?)", (sha, db.now()))
|
||
r = jira_fetch.request_fetch(fconn, root, "31337", "x")
|
||
assert r["state"] == "ready" and r["filename"] == "ОСВ 60.xlsx"
|
||
assert fconn.execute("SELECT COUNT(*) AS n FROM command").fetchone()["n"] == 0
|
||
with pytest.raises(KeyError):
|
||
jira_fetch.request_fetch(fconn, root, "999", "x")
|
||
|
||
|
||
def test_request_fetch_agent_too_old(fconn, tmp_path):
|
||
fconn.execute("UPDATE agent_status SET agent_version = '13' WHERE agent = 'jira'")
|
||
with pytest.raises(jira_fetch.AgentTooOld) as e:
|
||
jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")
|
||
assert str(e.value) == "агент v13 не умеет скачивать вложения — нужен v14"
|
||
fconn.execute("UPDATE agent_status SET agent_version = '14' WHERE agent = 'jira'")
|
||
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
|
||
fconn.execute("UPDATE agent_status SET agent_version = NULL WHERE agent = 'jira'")
|
||
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
|
||
|
||
|
||
def test_request_fetch_after_failed(fconn, tmp_path):
|
||
root = tmp_path / "files"
|
||
first = jira_fetch.request_fetch(fconn, root, "31337", "x")
|
||
cmd = queue.claim(fconn, "jira")
|
||
queue.report_result(fconn, cmd["id"], "failed", error="нет сети", files_dir=root)
|
||
again = jira_fetch.request_fetch(fconn, root, "31337", "x")
|
||
assert again["state"] == "fetching" and again["batch_id"] != first["batch_id"]
|
||
|
||
|
||
def report_done(conn, root, result):
|
||
queue.create_batch(conn, "x", "claude", [fetch()])
|
||
cmd = queue.claim(conn, "jira")
|
||
return queue.report_result(conn, cmd["id"], "done", result=result, files_dir=root)
|
||
|
||
|
||
def test_report_fetch_done_records_file(fconn, tmp_path):
|
||
root, sha = put_file(tmp_path)
|
||
cmd = report_done(fconn, root, {"file": sha, "size": 3})
|
||
assert cmd["status"] == "done"
|
||
row = fconn.execute("SELECT * FROM attachment_file WHERE attachment_id = '31337'").fetchone()
|
||
assert row["file"] == sha and row["size"] == 3
|
||
assert attachments.state(fconn, root, "31337")["state"] == "ready"
|
||
|
||
|
||
def test_report_fetch_done_missing_file(fconn, tmp_path):
|
||
root = tmp_path / "files"
|
||
cmd = report_done(fconn, root, {"file": FSHA, "size": 3})
|
||
assert cmd["status"] == "failed" and cmd["error"] == f"хаб: файл {FSHA} не найден в хранилище"
|
||
assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0
|
||
# после уборки файла (done → файла нет) и повторной постановки — новый пакет
|
||
assert jira_fetch.request_fetch(fconn, root, "31337", "x")["state"] == "fetching"
|
||
|
||
|
||
def test_report_fetch_wrong_size(fconn, tmp_path):
|
||
root, sha = put_file(tmp_path)
|
||
cmd = report_done(fconn, root, {"file": sha, "size": 99})
|
||
assert cmd["status"] == "failed" and cmd["error"] == f"хаб: файл {sha} не найден в хранилище"
|
||
assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0
|
||
|
||
|
||
def test_request_fetch_after_done_file_swept(fconn, tmp_path):
|
||
root, sha = put_file(tmp_path)
|
||
report_done(fconn, root, {"file": sha, "size": 3})
|
||
files.path(root, sha).unlink() # уборка файла
|
||
r = jira_fetch.request_fetch(fconn, root, "31337", "x")
|
||
assert r["state"] == "fetching"
|
||
assert fconn.execute("SELECT COUNT(*) AS n FROM command WHERE type = 'fetch_attachment'").fetchone()["n"] == 2
|
||
|
||
|
||
def test_estimate_sums_module_pauses_replaces_commands_estimate(conn):
|
||
"""Замена утверждения test_commands про commands.estimate (функция ушла в ядро): сумма пауз реального модуля jira
|
||
через pacing -> hooks.pause -> queue.estimate."""
|
||
settings.set_(conn, "pacing", {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2})
|
||
assert queue.estimate(conn, ["ensure_fields", "ensure_comment", "refresh"]) == 152
|
||
|
||
|
||
def test_sprint_batch_tail_is_project_jql(conn, add_project): # design #13, R-10
|
||
add_project(conn); add_project(conn, "C", board_id=None)
|
||
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["AWPCA"]}], [])
|
||
b = queue.create_batch(conn, "x", "claude", [meta(), {"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA")
|
||
cmds = queue.batch_view(conn, b["id"])["commands"]
|
||
assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "ensure_rank", "refresh"]
|
||
assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}
|
||
|
||
|
||
def test_rank_batch_tail_by_keys(conn, add_project):
|
||
add_project(conn)
|
||
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}], [])
|
||
b = queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "after": "Z"}}], project="AWPCA")
|
||
assert queue.batch_view(conn, b["id"])["commands"][-1]["params"] == {"keys": ["X", "Z"]}
|
||
|
||
|
||
def test_retry_sprint_meta_gets_jql_tail(conn, add_project):
|
||
add_project(conn)
|
||
b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA")
|
||
cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
|
||
r = queue.retry_command(conn, cid, "claude")
|
||
cmds = queue.batch_view(conn, r["id"])["commands"]
|
||
assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"]
|
||
assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}
|