test: соединения SQLite в тестах закрываются, утечка — ошибка набора (M-9/O-4 #4) (#32)

Фикстура open_db в tests/conftest.py, фикстуры conn вместо хелперов fresh()/_mirror(); filterwarnings: error::ResourceWarning и error::pytest.PytestUnraisableExceptionWarning (решение 14, R-5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 10:26:38 +04:00
parent be50525ed6
commit ea37d54946
10 changed files with 144 additions and 166 deletions

View File

@ -48,4 +48,3 @@
## Открытые вопросы ## Открытые вопросы
- Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже. - Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже.
- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их. - Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их.
- Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`).

View File

@ -31,6 +31,7 @@
## Тесты ## Тесты
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются. - Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
- SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют. - SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют.
- Соединение SQLite в тесте — через фикстуру `open_db` (`tests/conftest.py`) или фикстуру `conn` модуля, иначе с явным `close()`: `pyproject.toml` (`filterwarnings`) делает незакрытое соединение ошибкой набора.
- Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе. - Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе.
## Первая подсистема ## Первая подсистема

View File

@ -20,3 +20,6 @@ packages = ["src/jira_hub"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
# незакрытое соединение sqlite — ошибка набора: ResourceWarning из __del__ pytest выдаёт как
# PytestUnraisableExceptionWarning, поэтому нужны оба фильтра (hub-hygiene-d, решение 14)
filterwarnings = ["error::ResourceWarning", "error::pytest.PytestUnraisableExceptionWarning"]

20
tests/conftest.py Normal file
View File

@ -0,0 +1,20 @@
"""Общие фикстуры тестов. Соединения SQLite, открытые тестом, закрываются после него — в тестах нет
ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4)."""
import pytest
from jira_hub import db
@pytest.fixture
def open_db():
"""Фабрика соединений: open_db() — база в памяти, open_db(path) — файл. Все закрываются после теста."""
opened = []
def make(path=":memory:"):
conn = db.connect(path)
opened.append(conn)
return conn
yield make
for conn in opened:
conn.close()

View File

@ -1,6 +1,6 @@
import pytest import pytest
from jira_hub import commands, db, mirror from jira_hub import commands, mirror
from jira_hub.commands import CommandError from jira_hub.commands import CommandError
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')" NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"
@ -48,15 +48,17 @@ def test_substitute_and_estimate():
assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152 assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152
def _mirror(): @pytest.fixture
conn = db.connect(":memory:") def conn(open_db):
mirror.apply_state(conn, [ """Зеркало в памяти: A-1 (Готово, Спринт 2, метки AWPCA и kind-dev) и A-2, связь A-1 → A-2; закрывается после теста."""
c = open_db()
mirror.apply_state(c, [
{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий", {"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий",
"duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2", "duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2",
"created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"}, "created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"},
{"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"}, {"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"},
], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) ], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
return conn return c
LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"} LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"}
@ -78,27 +80,24 @@ LT = {"связанные": "ASUPR_Связанные", "блокирует на
({"type": "refresh", "params": {"keys": ["A-1"]}}, False), ({"type": "refresh", "params": {"keys": ["A-1"]}}, False),
({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False), ({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False),
]) ])
def test_check_confirmed(cmd, expected): def test_check_confirmed(conn, cmd, expected):
ok, _ = commands.check_confirmed(_mirror(), cmd, LT) ok, _ = commands.check_confirmed(conn, cmd, LT)
assert ok is expected assert ok is expected
def test_ensure_link_reverse_direction_with_both_keys_in_mirror(): def test_ensure_link_reverse_direction_with_both_keys_in_mirror(conn):
# A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт # A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт
conn = _mirror()
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT) ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT)
assert ok is False assert ok is False
def test_ensure_link_conflict_other_type_blocks_confirmation(): def test_ensure_link_conflict_other_type_blocks_confirmation(conn):
conn = _mirror()
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')") conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT) ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False assert ok is False
def test_ensure_link_conflict_reverse_blocks_confirmation(): def test_ensure_link_conflict_reverse_blocks_confirmation(conn):
conn = _mirror()
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')") conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT) ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False assert ok is False
@ -108,9 +107,8 @@ def _ensure_issue(summary):
return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}} return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}
def test_ensure_issue_never_confirmed_by_state(): def test_ensure_issue_never_confirmed_by_state(conn):
"""bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом.""" """bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом."""
conn = _mirror()
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None) assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None)

View File

@ -7,8 +7,8 @@ import threading
from jira_hub import backup, db, paths, queue, settings from jira_hub import backup, db, paths, queue, settings
def test_connect_creates_schema(): def test_connect_creates_schema(open_db):
conn = db.connect(":memory:") conn = open_db()
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
@ -59,10 +59,11 @@ def test_connect_is_idempotent(tmp_path):
db.connect(path).close() db.connect(path).close()
conn = db.connect(path) conn = db.connect(path)
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
conn.close()
def test_transaction_rolls_back_on_error(tmp_path): def test_transaction_rolls_back_on_error(tmp_path, open_db):
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
try: try:
with db.transaction(conn): with db.transaction(conn):
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')") conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')")
@ -72,8 +73,8 @@ def test_transaction_rolls_back_on_error(tmp_path):
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
def test_nested_transaction_joins_outer(tmp_path): def test_nested_transaction_joins_outer(tmp_path, open_db):
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
try: try:
with db.transaction(conn): with db.transaction(conn):
with db.transaction(conn): with db.transaction(conn):
@ -86,8 +87,8 @@ def test_nested_transaction_joins_outer(tmp_path):
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную
def test_nested_transaction_error_propagates_to_outer(tmp_path): def test_nested_transaction_error_propagates_to_outer(tmp_path, open_db):
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
try: try:
with db.transaction(conn): with db.transaction(conn):
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')") conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')")
@ -103,8 +104,8 @@ def test_parse_and_utcnow():
assert db.parse(db.now()) <= db.utcnow() assert db.parse(db.now()) <= db.utcnow()
def test_nested_transaction_rollback_is_local(tmp_path): def test_nested_transaction_rollback_is_local(tmp_path, open_db):
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
with db.transaction(conn): with db.transaction(conn):
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')") conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')")
try: try:

View File

@ -1,6 +1,16 @@
import csv import csv
from jira_hub import db, importer, mirror, settings import pytest
from jira_hub import importer, mirror, settings
@pytest.fixture
def conn(open_db):
"""База в памяти с настройками по умолчанию; закрывается после теста."""
c = open_db()
settings.ensure_defaults(c)
return c
def write_csv(path, header, rows): def write_csv(path, header, rows):
@ -29,10 +39,8 @@ def make_package(folder):
["new:3", "связанные", "AURORA-1533", "z"]]) ["new:3", "связанные", "AURORA-1533", "z"]])
def test_import_notes_and_report(tmp_path): def test_import_notes_and_report(conn, tmp_path):
make_package(tmp_path) make_package(tmp_path)
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
@ -60,10 +68,8 @@ def write_tasks(folder, comment):
[["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]]) [["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]])
def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path): def test_reimport_keeps_hub_edit_when_cell_empty(conn, tmp_path):
write_tasks(tmp_path, "Актуализация 10.10.2026") write_tasks(tmp_path, "Актуализация 10.10.2026")
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
importer.import_package(conn, tmp_path) importer.import_package(conn, tmp_path)
mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub") mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub")
write_tasks(tmp_path, "") # пустая ячейка в CSV write_tasks(tmp_path, "") # пустая ячейка в CSV
@ -73,20 +79,16 @@ def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path):
assert n["rest_text"] == "Прогнать" assert n["rest_text"] == "Прогнать"
def test_reimport_updates_nonempty_cell(tmp_path): def test_reimport_updates_nonempty_cell(conn, tmp_path):
write_tasks(tmp_path, "Первая версия") write_tasks(tmp_path, "Первая версия")
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
importer.import_package(conn, tmp_path) importer.import_package(conn, tmp_path)
write_tasks(tmp_path, "Вторая версия") write_tasks(tmp_path, "Вторая версия")
importer.import_package(conn, tmp_path) importer.import_package(conn, tmp_path)
assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия" assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия"
def test_import_does_not_touch_issue_tables(tmp_path): def test_import_does_not_touch_issue_tables(conn, tmp_path):
make_package(tmp_path) make_package(tmp_path)
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}], mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}],
[{"source_key": "AURORA-1533", "target_key": "AURORA-1416", "link_type": "x"}]) [{"source_key": "AURORA-1533", "target_key": "AURORA-1416", "link_type": "x"}])
count = lambda t: conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"] count = lambda t: conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"]
@ -95,20 +97,16 @@ def test_import_does_not_touch_issue_tables(tmp_path):
assert (count("issue"), count("issue_link")) == before assert (count("issue"), count("issue_link")) == before
def test_missing_csv_files_give_empty_report(tmp_path): def test_missing_csv_files_give_empty_report(conn, tmp_path):
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
rep = importer.import_package(conn, tmp_path) rep = importer.import_package(conn, tmp_path)
assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []} assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []}
def test_reversed_link_reported_separately(tmp_path): def test_reversed_link_reported_separately(conn, tmp_path):
make_package(tmp_path) make_package(tmp_path)
write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"], write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"],
[["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"], [["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"],
["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]]) ["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]])
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])

View File

@ -3,6 +3,12 @@ import pytest
from jira_hub import db, mirror from jira_hub import db, mirror
@pytest.fixture
def conn(open_db):
"""Пустое зеркало в памяти; закрывается после теста."""
return open_db()
def issue(key, **over): def issue(key, **over):
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст", base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст",
"status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me", "status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me",
@ -13,8 +19,7 @@ def issue(key, **over):
return base return base
def test_apply_state_upserts_and_replaces_links(): def test_apply_state_upserts_and_replaces_links(conn):
conn = db.connect(":memory:")
links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}] links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}]
out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links) out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links)
assert out == {"issues": 2, "links": 1} assert out == {"issues": 2, "links": 1}
@ -26,16 +31,14 @@ def test_apply_state_upserts_and_replaces_links():
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}] assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}]
def test_links_from_other_source_survive(): def test_links_from_other_source_survive(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1"), issue("A-2")], mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]) [{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}] assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}]
def test_apply_state_marks_neighbors(): def test_apply_state_marks_neighbors(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"}) mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"})
assert mirror.get_issue(conn, "B-9")["neighbor"] is True assert mirror.get_issue(conn, "B-9")["neighbor"] is True
assert mirror.get_issue(conn, "A-1")["neighbor"] is False assert mirror.get_issue(conn, "A-1")["neighbor"] is False
@ -44,8 +47,7 @@ def test_apply_state_marks_neighbors():
assert mirror.known_keys(conn) == ["A-1", "B-9"] assert mirror.known_keys(conn) == ["A-1", "B-9"]
def test_partial_load_never_lowers_neighbor(): def test_partial_load_never_lowers_neighbor(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"}) mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"})
mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys} mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys}
assert mirror.get_issue(conn, "B-9")["neighbor"] is True assert mirror.get_issue(conn, "B-9")["neighbor"] is True
@ -53,8 +55,7 @@ def test_partial_load_never_lowers_neighbor():
assert mirror.get_issue(conn, "B-9")["neighbor"] is False assert mirror.get_issue(conn, "B-9")["neighbor"] is False
def test_note_survives_refresh_and_renames(): def test_note_survives_refresh_and_renames(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import") mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import")
mirror.rename_note(conn, "new:7", "A-5") mirror.rename_note(conn, "new:7", "A-5")
mirror.apply_state(conn, [issue("A-5")], []) mirror.apply_state(conn, [issue("A-5")], [])
@ -65,8 +66,7 @@ def test_note_survives_refresh_and_renames():
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x") mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
def test_list_issues_filters(): def test_list_issues_filters(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"), mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"),
issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]), issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]),
issue("A-3", sprint="Спринт 2", duedate=None)], []) issue("A-3", sprint="Спринт 2", duedate=None)], [])
@ -80,31 +80,27 @@ def test_list_issues_filters():
assert len(mirror.list_issues(conn, None, limit=1)) == 1 assert len(mirror.list_issues(conn, None, limit=1)) == 1
def test_rename_note_same_key_keeps_note(): def test_rename_note_same_key_keeps_note(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1")], []) mirror.apply_state(conn, [issue("A-1")], [])
mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x") mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x")
mirror.rename_note(conn, "A-1", "A-1") mirror.rename_note(conn, "A-1", "A-1")
assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе" assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе"
def test_rename_note_missing_is_noop(): def test_rename_note_missing_is_noop(conn):
conn = db.connect(":memory:")
mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает
assert mirror.get_note(conn, "new:404") is None assert mirror.get_note(conn, "new:404") is None
assert mirror.get_note(conn, "A-9") is None assert mirror.get_note(conn, "A-9") is None
def test_set_note_partial_keeps_other_fields(): def test_set_note_partial_keeps_other_fields(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x") mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y") got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано" assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
assert got["fact_status"] == "Готово" and got["updated_by"] == "y" assert got["fact_status"] == "Готово" and got["updated_by"] == "y"
def test_rename_note_merges_with_existing(): def test_rename_note_merges_with_existing(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human") mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import") mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import")
mirror.rename_note(conn, "new:5", "A-5") mirror.rename_note(conn, "new:5", "A-5")
@ -116,8 +112,7 @@ def test_rename_note_merges_with_existing():
assert mirror.get_note(conn, "new:5") is None assert mirror.get_note(conn, "new:5") is None
def test_rename_note_keeps_newer_real_key_stamp(): def test_rename_note_keeps_newer_real_key_stamp(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import") mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import")
mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human") mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human")
conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'") conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'")
@ -128,8 +123,7 @@ def test_rename_note_keeps_newer_real_key_stamp():
assert note["fact_status"] == "В работе" and note["owner_note"] == "позже" assert note["fact_status"] == "В работе" and note["owner_note"] == "позже"
def test_rename_note_empty_string_does_not_overwrite(): def test_rename_note_empty_string_does_not_overwrite(conn):
conn = db.connect(":memory:")
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human") mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import") mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import")
mirror.rename_note(conn, "new:6", "A-6") mirror.rename_note(conn, "new:6", "A-6")
@ -151,8 +145,7 @@ def _sync_run(conn, started, finished, kind, query, status="done", count=5):
(started, finished, kind, query, count, command_id)) (started, finished, kind, query, count, command_id))
def test_board_snapshot_raw_mirror_data(): def test_board_snapshot_raw_mirror_data(conn):
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None), mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None),
issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"), issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"),
issue("AURORA-1621", epic_key="AURORA-1351")], issue("AURORA-1621", epic_key="AURORA-1351")],
@ -171,8 +164,7 @@ def test_board_snapshot_raw_mirror_data():
assert snap["last_sync"] is None and snap["generated_at"] assert snap["last_sync"] is None and snap["generated_at"]
def test_board_last_sync_is_last_successful_full_refresh(): def test_board_last_sync_is_last_successful_full_refresh(conn):
conn = db.connect(":memory:")
_sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198) _sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198)
_sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1) _sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1)
_sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1) _sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1)
@ -182,9 +174,8 @@ def test_board_last_sync_is_last_successful_full_refresh():
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198} "finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
def test_board_inside_open_transaction_keeps_it_open(): def test_board_inside_open_transaction_keeps_it_open(conn):
"""M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою.""" """M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою."""
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("AURORA-1620")], []) mirror.apply_state(conn, [issue("AURORA-1620")], [])
with db.transaction(conn): with db.transaction(conn):
assert conn.in_transaction assert conn.in_transaction

View File

@ -7,19 +7,20 @@ from jira_hub.queue import ClaimedError
LT = {"связанные": "ASUPR_Связанные"} LT = {"связанные": "ASUPR_Связанные"}
def fresh(): @pytest.fixture
conn = db.connect(":memory:") def conn(open_db):
settings.ensure_defaults(conn) """База очереди в памяти с настройками по умолчанию и типами связей LT; закрывается после теста."""
settings.set_(conn, "link_types", LT) c = open_db()
return conn settings.ensure_defaults(c)
settings.set_(c, "link_types", LT)
return c
def fields(key, **extra): def fields(key, **extra):
return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}} return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}}
def test_create_batch_adds_tail_refresh_and_estimate(): def test_create_batch_adds_tail_refresh_and_estimate(conn):
conn = fresh()
b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}]) 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"]) view = queue.batch_view(conn, b["id"])
assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"] assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"]
@ -28,16 +29,14 @@ def test_create_batch_adds_tail_refresh_and_estimate():
assert view["status"] == "queued" and view["author"] == "claude" assert view["status"] == "queued" and view["author"] == "claude"
def test_refresh_only_batch_has_no_tail(): def test_refresh_only_batch_has_no_tail(conn):
conn = fresh()
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
assert b["count"] == 1 assert b["count"] == 1
with pytest.raises(CommandError): with pytest.raises(CommandError):
queue.create_batch(conn, "x", "claude", [{"type": "nope"}]) queue.create_batch(conn, "x", "claude", [{"type": "nope"}])
def test_claim_order_pause_and_report(): def test_claim_order_pause_and_report(conn):
conn = fresh()
b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")]) b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")])
b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")]) b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")])
c = queue.claim(conn, agent_version=1, settings_version=1) c = queue.claim(conn, agent_version=1, settings_version=1)
@ -61,13 +60,13 @@ def test_claim_order_pause_and_report():
assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running" assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
def test_claim_is_exclusive(tmp_path): def test_claim_is_exclusive(tmp_path, open_db):
import threading import threading
path = tmp_path / "hub.sqlite" path = tmp_path / "hub.sqlite"
setup = db.connect(path) setup = open_db(path)
settings.ensure_defaults(setup) settings.ensure_defaults(setup)
queue.create_batch(setup, "x", "claude", [fields("A-1")]) queue.create_batch(setup, "x", "claude", [fields("A-1")])
conns = [db.connect(path), db.connect(path)] conns = [open_db(path), open_db(path)]
barrier, results = threading.Barrier(2), [] barrier, results = threading.Barrier(2), []
def worker(c): def worker(c):
@ -83,8 +82,7 @@ def test_claim_is_exclusive(tmp_path):
assert row["n"] == 1 and row["d"] == len(got) assert row["n"] == 1 and row["d"] == len(got)
def test_backup_failure_does_not_block_claim(caplog): def test_backup_failure_does_not_block_claim(conn, caplog):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
def broken(label): def broken(label):
@ -96,8 +94,7 @@ def test_backup_failure_does_not_block_claim(caplog):
assert "не создана" in caplog.text assert "не создана" in caplog.text
def test_temp_key_substitution_and_skip(): def test_temp_key_substitution_and_skip(conn):
conn = fresh()
mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import") mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import")
queue.create_batch(conn, "создать", "claude", [ queue.create_batch(conn, "создать", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}}, {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
@ -120,8 +117,7 @@ def test_temp_key_substitution_and_skip():
assert view["commands"][3]["error"] == "задача new:61 не создана" assert view["commands"][3]["error"] == "задача new:61 не создана"
def test_cancel_and_retry(): def test_cancel_and_retry(conn):
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn) c = queue.claim(conn)
with pytest.raises(ClaimedError): with pytest.raises(ClaimedError):
@ -139,16 +135,14 @@ def test_cancel_and_retry():
assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"] assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"]
def test_report_after_force_cancel_keeps_cancelled(): def test_report_after_force_cancel_keeps_cancelled(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
c = queue.claim(conn) c = queue.claim(conn)
queue.cancel_command(conn, c["id"], force=True) queue.cancel_command(conn, c["id"], force=True)
assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled" assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled"
def test_reconcile_confirms_by_state(): def test_reconcile_confirms_by_state(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
c = queue.claim(conn) c = queue.claim(conn)
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
@ -160,8 +154,7 @@ def test_reconcile_confirms_by_state():
assert queue.batch_view(conn, b["id"])["status"] == "done" assert queue.batch_view(conn, b["id"])["status"] == "done"
def test_auto_refresh_conditions(): def test_auto_refresh_conditions(conn):
conn = fresh()
assert queue.maybe_auto_refresh(conn) is None # агент не на связи assert queue.maybe_auto_refresh(conn) is None # агент не на связи
queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста
b = queue.maybe_auto_refresh(conn) b = queue.maybe_auto_refresh(conn)
@ -177,8 +170,7 @@ def test_auto_refresh_conditions():
assert queue.maybe_auto_refresh(conn) is None assert queue.maybe_auto_refresh(conn) is None
def test_backup_called_for_first_write_only(tmp_path): def test_backup_called_for_first_write_only(conn, tmp_path):
conn = fresh()
calls = [] calls = []
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c1 = queue.claim(conn, backup=calls.append) c1 = queue.claim(conn, backup=calls.append)
@ -187,28 +179,27 @@ def test_backup_called_for_first_write_only(tmp_path):
assert calls == [f"batch-{c1['batch_id']}"] assert calls == [f"batch-{c1['batch_id']}"]
def test_no_backup_for_refresh_batch(): def test_no_backup_for_refresh_batch(conn):
conn = fresh()
calls = [] calls = []
queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}]) queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
queue.claim(conn, backup=calls.append) queue.claim(conn, backup=calls.append)
assert calls == [] assert calls == []
def test_make_backup_rotates(tmp_path): def test_make_backup_rotates(tmp_path, open_db):
from jira_hub import backup from jira_hub import backup
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
for i in range(4): for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2) backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite")) names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
assert len(names) == 2 and names[-1].endswith("-b3.sqlite") assert len(names) == 2 and names[-1].endswith("-b3.sqlite")
assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 assert open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
def test_rotation_keeps_schema_migration_copy(tmp_path): def test_rotation_keeps_schema_migration_copy(tmp_path, open_db):
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью).""" """Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
from jira_hub import backup from jira_hub import backup
conn = db.connect(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0) backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
for i in range(4): for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2) backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2)
@ -224,9 +215,8 @@ def _claimed_ensure_issue(conn, summary="Тема"):
return c return c
def test_reconcile_does_not_confirm_ensure_issue_without_reporter(): def test_reconcile_does_not_confirm_ensure_issue_without_reporter(conn):
"""ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает.""" """ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""
conn = fresh()
c = _claimed_ensure_issue(conn) c = _claimed_ensure_issue(conn)
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t") mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
created = db.utcnow().isoformat() created = db.utcnow().isoformat()
@ -236,9 +226,8 @@ def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None 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(monkeypatch): def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch):
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются.""" """M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
conn = fresh()
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
c = queue.claim(conn) c = queue.claim(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], [])
@ -264,8 +253,7 @@ def _two_batches_with_dependent(conn):
return a, b, first return a, b, first
def test_redelivered_definer_blocks_dependents(): def test_redelivered_definer_blocks_dependents(conn):
conn = fresh()
a, b, first = _two_batches_with_dependent(conn) a, b, first = _two_batches_with_dependent(conn)
assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут
conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],)) conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],))
@ -273,8 +261,7 @@ def test_redelivered_definer_blocks_dependents():
assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9" assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9"
def test_auto_refresh_waits_for_claimed_without_report(): def test_auto_refresh_waits_for_claimed_without_report(conn):
conn = fresh()
queue.claim(conn, agent_version=1, settings_version=1) # агент на связи queue.claim(conn, agent_version=1, settings_version=1) # агент на связи
queue.create_batch(conn, "y", "claude", [fields("A-3")]) queue.create_batch(conn, "y", "claude", [fields("A-3")])
queue.claim(conn) # выдана, отчёта нет queue.claim(conn) # выдана, отчёта нет
@ -283,8 +270,7 @@ def test_auto_refresh_waits_for_claimed_without_report():
assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята
def test_redelivery_of_lost_claim_before_queue(): def test_redelivery_of_lost_claim_before_queue(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn) c = queue.claim(conn)
again = queue.claim(conn) again = queue.claim(conn)
@ -297,8 +283,7 @@ def test_redelivery_of_lost_claim_before_queue():
assert queue.agent_view(conn)["redelivered"] == [] assert queue.agent_view(conn)["redelivered"] == []
def test_redelivery_limit_fails_command(): def test_redelivery_limit_fails_command(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn) c = queue.claim(conn)
queue.claim(conn) queue.claim(conn)
@ -310,8 +295,7 @@ def test_redelivery_limit_fails_command():
assert queue.MAX_DELIVERIES == 3 assert queue.MAX_DELIVERIES == 3
def test_redelivery_oldest_first(): def test_redelivery_oldest_first(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
queue.create_batch(conn, "y", "claude", [fields("A-2")]) queue.create_batch(conn, "y", "claude", [fields("A-2")])
rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall() rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall()
@ -320,16 +304,14 @@ def test_redelivery_oldest_first():
assert queue.claim(conn)["id"] == rows[1]["id"] assert queue.claim(conn)["id"] == rows[1]["id"]
def test_redelivered_refresh_keeps_one_sync_run(): def test_redelivered_refresh_keeps_one_sync_run(conn):
conn = fresh()
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn) r = queue.claim(conn)
assert queue.claim(conn)["id"] == r["id"] assert queue.claim(conn)["id"] == r["id"]
assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1 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(): def test_redelivery_does_not_repeat_backup(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
labels = [] labels = []
queue.claim(conn, backup=labels.append) queue.claim(conn, backup=labels.append)
@ -337,9 +319,8 @@ def test_redelivery_does_not_repeat_backup():
assert len(labels) == 1 assert len(labels) == 1
def test_migrated_claimed_is_redelivered(): def test_migrated_claimed_is_redelivered(conn):
"""Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2.""" """Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2."""
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] 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,)) conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,))
@ -347,8 +328,7 @@ def test_migrated_claimed_is_redelivered():
assert queue.get_command(conn, cid)["deliveries"] == 2 assert queue.get_command(conn, cid)["deliveries"] == 2
def test_report_for_queued_command_rejected(): def test_report_for_queued_command_rejected(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
with pytest.raises(queue.NotClaimedError): with pytest.raises(queue.NotClaimedError):
@ -356,8 +336,7 @@ def test_report_for_queued_command_rejected():
assert queue.get_command(conn, cid)["status"] == "queued" assert queue.get_command(conn, cid)["status"] == "queued"
def test_late_report_after_force_cancel_keeps_issue_key(): def test_late_report_after_force_cancel_keeps_issue_key(conn):
conn = fresh()
mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import") mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import")
queue.create_batch(conn, "x", "claude", [ queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
@ -371,8 +350,7 @@ def test_late_report_after_force_cancel_keeps_issue_key():
assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет
def test_late_failed_report_after_force_cancel_changes_nothing(): def test_late_failed_report_after_force_cancel_changes_nothing(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [ queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
c = queue.claim(conn) c = queue.claim(conn)
@ -388,16 +366,14 @@ def _fail_by_limit(conn, cid):
assert queue.get_command(conn, cid)["status"] == "failed" assert queue.get_command(conn, cid)["status"] == "failed"
def test_error_is_trimmed_to_1500(): def test_error_is_trimmed_to_1500(conn):
conn = fresh()
queue.create_batch(conn, "x", "claude", [fields("A-1")]) queue.create_batch(conn, "x", "claude", [fields("A-1")])
c = queue.claim(conn) c = queue.claim(conn)
out = queue.report_result(conn, c["id"], "failed", error="е" * 5000) out = queue.report_result(conn, c["id"], "failed", error="е" * 5000)
assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500 assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500
def test_late_done_after_delivery_limit_keeps_issue_key(): def test_late_done_after_delivery_limit_keeps_issue_key(conn):
conn = fresh()
mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import") mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import")
queue.create_batch(conn, "x", "claude", [ queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}]) {"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
@ -411,8 +387,7 @@ def test_late_done_after_delivery_limit_keeps_issue_key():
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
def test_cancelled_batch_with_done_command_is_cancelled(): def test_cancelled_batch_with_done_command_is_cancelled(conn):
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")]) b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")])
first = queue.claim(conn) first = queue.claim(conn)
queue.report_result(conn, first["id"], "done", result={"changed": []}) queue.report_result(conn, first["id"], "done", result={"changed": []})
@ -424,8 +399,7 @@ def test_cancelled_batch_with_done_command_is_cancelled():
assert view["counts"]["done"] == 2 assert view["counts"]["done"] == 2
def test_empty_full_refresh_keeps_neighbors(): def test_empty_full_refresh_keeps_neighbors(conn):
conn = fresh()
jql = settings.get(conn, "base_jql") jql = settings.get(conn, "base_jql")
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [], mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
@ -435,8 +409,7 @@ def test_empty_full_refresh_keeps_neighbors():
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0 assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
def test_cancel_finished_batch_stays_done(): def test_cancel_finished_batch_stays_done(conn):
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1")]) b = queue.create_batch(conn, "x", "claude", [fields("A-1")])
while (c := queue.claim(conn)): while (c := queue.claim(conn)):
queue.report_result(conn, c["id"], "done", result={"changed": []}) queue.report_result(conn, c["id"], "done", result={"changed": []})
@ -445,8 +418,7 @@ def test_cancel_finished_batch_stays_done():
assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None 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(): def test_redelivered_full_refresh_marks_neighbors_once(conn):
conn = fresh()
jql = settings.get(conn, "base_jql") jql = settings.get(conn, "base_jql")
mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00") 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}}]) queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
@ -463,8 +435,7 @@ def test_redelivered_full_refresh_marks_neighbors_once():
assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1 assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1
def test_redelivered_refresh_resets_counts(): def test_redelivered_refresh_resets_counts(conn):
conn = fresh()
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn) r = queue.claim(conn)
queue.record_state(conn, r["id"], 150, 40) queue.record_state(conn, r["id"], 150, 40)
@ -476,8 +447,7 @@ def test_redelivered_refresh_resets_counts():
assert run["started_at"] == started assert run["started_at"] == started
def test_refresh_failed_by_limit_finishes_sync_run(): def test_refresh_failed_by_limit_finishes_sync_run(conn):
conn = fresh()
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn) r = queue.claim(conn)
for _ in range(queue.MAX_DELIVERIES): for _ in range(queue.MAX_DELIVERIES):
@ -486,8 +456,7 @@ def test_refresh_failed_by_limit_finishes_sync_run():
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"] assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
def test_cancel_batch_reports_still_claimed(): def test_cancel_batch_reports_still_claimed(conn):
conn = fresh()
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn) c = queue.claim(conn)
view = queue.cancel_batch(conn, b["id"]) view = queue.cancel_batch(conn, b["id"])

View File

@ -8,14 +8,15 @@ from jira_hub import db, settings
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
def fresh(): @pytest.fixture
conn = db.connect(":memory:") def conn(open_db):
settings.ensure_defaults(conn) """База в памяти с настройками по умолчанию; закрывается после теста."""
return conn c = open_db()
settings.ensure_defaults(c)
return c
def test_defaults_and_tokens(): def test_defaults_and_tokens(conn):
conn = fresh()
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA"
assert settings.get(conn, "pacing")["ensure_fields"] == 120 assert settings.get(conn, "pacing")["ensure_fields"] == 120
tok = settings.tokens(conn) tok = settings.tokens(conn)
@ -23,8 +24,7 @@ def test_defaults_and_tokens():
assert "tokens" not in settings.get_all(conn) assert "tokens" not in settings.get_all(conn)
def test_set_bumps_version(): def test_set_bumps_version(conn):
conn = fresh()
v0 = settings.get(conn, "settings_version") v0 = settings.get(conn, "settings_version")
v1 = settings.set_(conn, "pacing", {"ensure_fields": 5}) v1 = settings.set_(conn, "pacing", {"ensure_fields": 5})
assert v1 == v0 + 1 assert v1 == v0 + 1
@ -32,14 +32,12 @@ def test_set_bumps_version():
@pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"]) @pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"])
def test_protected_keys(key): def test_protected_keys(conn, key):
conn = fresh()
with pytest.raises(ValueError): with pytest.raises(ValueError):
settings.set_(conn, key, "x") settings.set_(conn, key, "x")
def test_init_from_config(): def test_init_from_config(conn):
conn = fresh()
cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
keys = settings.init_from_config(conn, cfg) keys = settings.init_from_config(conn, cfg)
assert "transitions_map" in keys assert "transitions_map" in keys