Files
orchestrator/tests/test_tracker_bump.py
claude-bot 05c17135c1
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 13s
feat(notifications): add bump mode + russify Telegram live-tracker
ORCH-042: new ORCH_TRACKER_MODE (Settings.tracker_mode, default edit) selects
the live-tracker card behaviour. bump mode re-creates the card at the bottom of
the chat on every update (delete_telegram + send silently + repoint message_id),
keeping the "one card per task" invariant: <=1 new message per call, repoint
only on successful send, delete result never gates the send. New never-raising
delete_telegram helper. Anything != "bump" resolves to edit (zero regression).

Also russify/cosmetic-fix the card text (both modes): "Подтверждение BRD" label,
 after approve-gate, Russian stage labels, "📦 Внедрено". Docs updated in the
same PR (CHANGELOG, internals.md, .env.example).

Refs: ORCH-042

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 10:13:49 +00:00

238 lines
8.4 KiB
Python

"""ORCH-042: bump-mode live tracker + delete_telegram helper.
bump = delete(old) + send(new, silent) + repoint message_id. One card per task,
always at the bottom. Covers AC-7..AC-14 (TC-07..TC-17). The edit-mode regression
stays in tests/test_telegram_tracker.py.
Isolated temp DB; no network (httpx / low-level helpers are patched per case).
"""
import os
import tempfile
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_tracker_bump.db")
os.environ["ORCH_DB_PATH"] = _test_db
from unittest.mock import MagicMock, patch # noqa: E402
import pytest # noqa: E402
import src.db as db_module # noqa: E402
from src.db import ( # noqa: E402
init_db, get_db, get_tracker_message_id, set_tracker_message_id,
)
from src import notifications as N # noqa: E402
@pytest.fixture(autouse=True)
def setup_db(monkeypatch):
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
yield
if os.path.exists(_test_db):
os.unlink(_test_db)
def _mk_task(stage="development", wid="ORCH-042"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
"VALUES (?, ?, ?, ?, ?, ?)",
("p1", wid, "orchestrator", "feature/ORCH-042-x", stage, "bump test"),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _bump_mode(monkeypatch):
monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False)
# --------------------------------------------------------------------------- #
# bump mode behaviour
# --------------------------------------------------------------------------- #
def test_bump_first_call_sends_silent_no_delete(monkeypatch):
"""TC-07/AC-7,AC-9: first call (no id) -> NO delete, silent send, id stored."""
_bump_mode(monkeypatch)
tid = _mk_task(stage="analysis")
sends = []
monkeypatch.setattr(N, "send_telegram",
lambda text, disable_notification=False:
sends.append(disable_notification) or 555)
monkeypatch.setattr(N, "delete_telegram",
lambda mid: (_ for _ in ()).throw(
AssertionError("delete must not run on first call")))
N.update_task_tracker(tid)
assert sends == [True] # exactly one silent send
assert get_tracker_message_id(tid) == 555
def test_bump_repeat_deletes_then_sends_and_repoints(monkeypatch):
"""TC-08/AC-8,AC-9,AC-10: repeat -> delete(old) THEN send(silent), id repointed."""
_bump_mode(monkeypatch)
tid = _mk_task()
set_tracker_message_id(tid, 100)
order = []
monkeypatch.setattr(N, "delete_telegram",
lambda mid: order.append(("delete", mid)) or True)
monkeypatch.setattr(N, "send_telegram",
lambda text, disable_notification=False:
order.append(("send", disable_notification)) or 200)
N.update_task_tracker(tid)
assert order == [("delete", 100), ("send", True)] # delete before send, silent
assert get_tracker_message_id(tid) == 200 # repointed to the new card
def test_bump_delete_fail_still_sends(monkeypatch):
"""TC-09/AC-11: delete_telegram->False -> new card still sent, id updated."""
_bump_mode(monkeypatch)
tid = _mk_task()
set_tracker_message_id(tid, 100)
sends = []
monkeypatch.setattr(N, "delete_telegram", lambda mid: False) # >48h / transient
monkeypatch.setattr(N, "send_telegram",
lambda text, disable_notification=False:
sends.append(disable_notification) or 201)
N.update_task_tracker(tid)
assert sends == [True] # exactly one send despite delete failing
assert get_tracker_message_id(tid) == 201
def test_bump_send_none_keeps_old_id(monkeypatch):
"""TC-10/AC-13: send->None (transient) -> id NOT wiped, one send attempt."""
_bump_mode(monkeypatch)
tid = _mk_task()
set_tracker_message_id(tid, 100)
sends = []
monkeypatch.setattr(N, "delete_telegram", lambda mid: True)
monkeypatch.setattr(N, "send_telegram",
lambda text, disable_notification=False:
sends.append(1) or None)
N.update_task_tracker(tid) # must not raise
assert len(sends) == 1 # exactly one (failed) attempt, no retry/dupe
assert get_tracker_message_id(tid) == 100 # pointer preserved, not None
def test_bump_one_card_per_call(monkeypatch):
"""TC-11/AC-10: at most one send_telegram per update_task_tracker call."""
_bump_mode(monkeypatch)
tid = _mk_task()
set_tracker_message_id(tid, 100)
sends = []
monkeypatch.setattr(N, "delete_telegram", lambda mid: True)
monkeypatch.setattr(N, "send_telegram",
lambda text, disable_notification=False:
sends.append(1) or 202)
N.update_task_tracker(tid)
assert len(sends) == 1
# --------------------------------------------------------------------------- #
# delete_telegram classification (httpx mocked, never raises)
# --------------------------------------------------------------------------- #
def _del_resp(ok, description=None):
resp = MagicMock()
body = {"ok": ok}
if description is not None:
body["description"] = description
resp.json.return_value = body
return resp
def _patch_tg_creds(monkeypatch):
monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "T", raising=False)
monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "C", raising=False)
def test_delete_ok_true(monkeypatch):
"""TC-12: ok:true -> True."""
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _del_resp(True)
assert N.delete_telegram(1) is True
@pytest.mark.parametrize("desc", [
"Bad Request: message to delete not found",
"Bad Request: message can't be deleted",
"Bad Request: MESSAGE_ID_INVALID",
])
def test_delete_gone_markers_are_true(monkeypatch, desc):
"""TC-13/AC-12: 'already gone / can't delete' -> True (not transient)."""
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _del_resp(False, desc)
assert N.delete_telegram(1) is True
@pytest.mark.parametrize("desc", [
"Bad Request: some other unexpected error",
"Internal Server Error",
])
def test_delete_unknown_or_5xx_is_false(monkeypatch, desc):
"""TC-14/AC-12: unknown ok:false / 5xx -> False (old may still be alive)."""
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.return_value = _del_resp(False, desc)
assert N.delete_telegram(1) is False
def test_delete_exception_is_false(monkeypatch):
"""TC-15/AC-12,AC-14: timeout/network -> False, never raises."""
_patch_tg_creds(monkeypatch)
with patch("src.notifications.httpx") as hx:
hx.post.side_effect = Exception("read timeout")
assert N.delete_telegram(1) is False
def test_delete_no_creds_is_false_and_no_http(monkeypatch):
"""TC-16/AC-12: no token/chat_id -> False, HTTP not called."""
monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "", raising=False)
monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "", raising=False)
with patch("src.notifications.httpx") as hx:
assert N.delete_telegram(1) is False
hx.post.assert_not_called()
# --------------------------------------------------------------------------- #
# never raises in either mode
# --------------------------------------------------------------------------- #
def test_update_task_tracker_never_raises_bump(monkeypatch):
"""TC-17/AC-14: bump path swallows a render/DB explosion."""
_bump_mode(monkeypatch)
tid = _mk_task()
monkeypatch.setattr(N, "render_task_tracker",
lambda task_id: (_ for _ in ()).throw(RuntimeError("boom")))
# Must not raise.
N.update_task_tracker(tid)
def test_update_task_tracker_never_raises_edit(monkeypatch):
"""TC-17/AC-14: edit path swallows a render/DB explosion."""
monkeypatch.setattr(N._get_settings(), "tracker_mode", "edit", raising=False)
tid = _mk_task()
monkeypatch.setattr(N, "render_task_tracker",
lambda task_id: (_ for _ in ()).throw(RuntimeError("boom")))
N.update_task_tracker(tid)