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>
238 lines
8.4 KiB
Python
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)
|