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