test_telegram_reactions.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945
  1. """Answering the outcome prompt with a Telegram reaction (#3046).
  2. The Bot API is stood in for at the httpx layer (the same ``_http_client``
  3. swap the forum-topic tests use), the database is the real test engine: the
  4. poller opens its own sessions, so the tests hand it the test session
  5. factory instead of the module-level one.
  6. """
  7. from datetime import timedelta
  8. from unittest.mock import AsyncMock, patch
  9. import httpx
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy import select, text
  13. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  14. from backend.app.models.archive import PrintArchive
  15. from backend.app.models.notification import TelegramPendingVerdict
  16. from backend.app.models.print_log import PrintLogEntry
  17. from backend.app.services.notification_service import TELEGRAM_REACTION_HINT, NotificationService, notification_service
  18. from backend.app.services.print_confirmation import apply_outcome_verdict, one_tap_url
  19. from backend.app.services.telegram_reactions import (
  20. CONFLICT_COOLDOWN,
  21. MAX_BACKOFF,
  22. TelegramReactionPoller,
  23. verdict_from_reaction,
  24. )
  25. from backend.app.utils.local_time import utcnow_naive
  26. BOT_TOKEN = "123456:AAbbCC"
  27. CHAT_ID = "-1002520100736"
  28. TELEGRAM_CONFIG = {"bot_token": BOT_TOKEN, "chat_id": CHAT_ID}
  29. THUMBS_UP = "\U0001f44d"
  30. THUMBS_DOWN = "\U0001f44e"
  31. class _FakeBotApi:
  32. """Stand-in for httpx.AsyncClient with scripted responses, in call order."""
  33. def __init__(self, responses=None):
  34. self.is_closed = False
  35. self.calls: list[dict] = []
  36. self.responses = list(responses or [])
  37. async def post(self, url, json=None, data=None, files=None):
  38. self.calls.append({"url": url, "json": json, "data": data, "files": files})
  39. if self.responses:
  40. nxt = self.responses.pop(0)
  41. if isinstance(nxt, Exception):
  42. raise nxt
  43. return nxt
  44. return httpx.Response(200, json={"ok": True, "result": []})
  45. async def aclose(self):
  46. self.is_closed = True
  47. def urls(self) -> list[str]:
  48. return [c["url"].rsplit("/", 1)[-1] for c in self.calls]
  49. def _updates(*updates: dict) -> httpx.Response:
  50. return httpx.Response(200, json={"ok": True, "result": list(updates)})
  51. def _reaction(update_id: int, message_id: int, emoji: str | None, chat_id: str = CHAT_ID, kind: str = "emoji") -> dict:
  52. new_reaction = [] if emoji is None else [{"type": kind, "emoji": emoji}]
  53. return {
  54. "update_id": update_id,
  55. "message_reaction": {
  56. "chat": {"id": int(chat_id)},
  57. "message_id": message_id,
  58. "user": {"id": 42},
  59. "date": 1_700_000_000,
  60. "old_reaction": [],
  61. "new_reaction": new_reaction,
  62. },
  63. }
  64. @pytest.fixture
  65. async def poller(test_engine):
  66. p = TelegramReactionPoller()
  67. p._session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  68. p._http_client = _FakeBotApi()
  69. yield p
  70. await p.aclose()
  71. def _track(poller: TelegramReactionPoller, *providers, token: str = BOT_TOKEN):
  72. """What sync() would record for these providers, without starting a task."""
  73. poller._providers.setdefault(token, set()).update(p.id for p in providers)
  74. def _polling(poller: TelegramReactionPoller, provider_id: int) -> bool:
  75. """Whether a running poll serves this provider."""
  76. return any(provider_id in ids for token, ids in poller._providers.items() if token in poller._tasks)
  77. @pytest.fixture
  78. def telegram_provider(notification_provider_factory):
  79. async def _create(**kwargs):
  80. defaults = {
  81. "name": "Telegram",
  82. "provider_type": "telegram",
  83. "config": TELEGRAM_CONFIG,
  84. "telegram_verdict_mode": "reactions",
  85. }
  86. defaults.update(kwargs)
  87. return await notification_provider_factory(**defaults)
  88. return _create
  89. @pytest.fixture
  90. def pending_factory(db_session):
  91. async def _create(provider_id: int, archive_id: int, message_id: int = 777, **kwargs):
  92. # A prompt only goes out with a live confirm token (the dispatch mints
  93. # one), and the send records it on the row: do the same here.
  94. if "confirm_token" not in kwargs:
  95. archive = await db_session.get(PrintArchive, archive_id)
  96. if archive.confirm_token is None:
  97. archive.confirm_token = f"tok-{archive_id}-{message_id}"
  98. kwargs["confirm_token"] = archive.confirm_token
  99. defaults = {
  100. "provider_id": provider_id,
  101. "chat_id": CHAT_ID,
  102. "message_id": message_id,
  103. "archive_id": archive_id,
  104. "has_caption": False,
  105. "message_text": "*Outcome?*\nTest_Print on X1C",
  106. }
  107. defaults.update(kwargs)
  108. row = TelegramPendingVerdict(**defaults)
  109. db_session.add(row)
  110. await db_session.commit()
  111. await db_session.refresh(row)
  112. return row
  113. return _create
  114. async def _pending_count(db_session) -> int:
  115. return len((await db_session.execute(select(TelegramPendingVerdict))).scalars().all())
  116. # ---------------------------------------------------------------------------
  117. # Shared verdict helper
  118. # ---------------------------------------------------------------------------
  119. class TestApplyOutcomeVerdict:
  120. @pytest.mark.asyncio
  121. @pytest.mark.integration
  122. async def test_first_verdict_wins_and_mirrors(self, db_session, printer_factory, archive_factory):
  123. printer = await printer_factory()
  124. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="tok")
  125. assert await apply_outcome_verdict(db_session, archive, "reject", source="reaction") is True
  126. await db_session.commit()
  127. assert archive.user_verdict == "reject"
  128. assert archive.user_verdict_source == "reaction"
  129. assert archive.user_verdict_at is not None
  130. # The capability is spent, not destroyed: the one-tap route can still
  131. # tell whoever taps an old button that this print was answered.
  132. assert archive.confirm_token == "tok"
  133. assert archive.confirm_token_used_at is not None
  134. entry = await db_session.scalar(
  135. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc())
  136. )
  137. assert entry.user_verdict == "reject"
  138. # A later verdict from any path is a no-op.
  139. assert await apply_outcome_verdict(db_session, archive, "good", source="reaction") is False
  140. assert archive.user_verdict == "reject"
  141. @pytest.mark.asyncio
  142. @pytest.mark.integration
  143. async def test_rejects_unknown_verdict(self, db_session, printer_factory, archive_factory):
  144. printer = await printer_factory()
  145. archive = await archive_factory(printer.id)
  146. with pytest.raises(ValueError):
  147. await apply_outcome_verdict(db_session, archive, "meh", source="reaction")
  148. with pytest.raises(ValueError):
  149. await apply_outcome_verdict(db_session, archive, "good", source="carrier-pigeon")
  150. # ---------------------------------------------------------------------------
  151. # Sending the prompt
  152. # ---------------------------------------------------------------------------
  153. class _SendCapture:
  154. def __init__(self, message_id=555):
  155. self.is_closed = False
  156. self.calls: list[dict] = []
  157. self.message_id = message_id
  158. async def post(self, url, data=None, files=None, json=None):
  159. self.calls.append({"url": url, "data": data, "files": files, "json": json})
  160. result = {"message_id": self.message_id, "chat": {"id": int(CHAT_ID)}}
  161. return httpx.Response(200, json={"ok": True, "result": result})
  162. CONFIRM_VARIABLES = {"good_url": "http://bambuddy.local/good", "reject_url": "http://bambuddy.local/reject"}
  163. class TestSendingThePrompt:
  164. async def _send(self, provider, db_session, archive_id, image_data=None):
  165. service = NotificationService()
  166. client = _SendCapture()
  167. service._http_client = client
  168. ok, _ = await service._send_to_provider(
  169. provider,
  170. "Outcome?",
  171. "How did it go",
  172. db_session,
  173. image_data=image_data,
  174. event_type="print_confirm_request",
  175. variables={**CONFIRM_VARIABLES, "archive_id": archive_id},
  176. )
  177. assert ok
  178. return client
  179. @pytest.mark.asyncio
  180. @pytest.mark.integration
  181. async def test_reactions_mode_drops_buttons_and_records_message(
  182. self, db_session, printer_factory, archive_factory, telegram_provider
  183. ):
  184. printer = await printer_factory()
  185. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="live-token")
  186. provider = await telegram_provider(telegram_verdict_mode="reactions")
  187. client = await self._send(provider, db_session, archive.id)
  188. body = client.calls[0]["json"]
  189. assert "reply_markup" not in body
  190. assert TELEGRAM_REACTION_HINT in body["text"]
  191. row = await db_session.scalar(select(TelegramPendingVerdict))
  192. assert row is not None
  193. assert row.provider_id == provider.id
  194. assert row.archive_id == archive.id
  195. assert row.message_id == 555
  196. assert row.chat_id == CHAT_ID
  197. assert row.confirm_token == "live-token", "the token the prompt's links carry"
  198. assert row.has_caption is False
  199. assert row.message_text and TELEGRAM_REACTION_HINT in row.message_text
  200. @pytest.mark.asyncio
  201. @pytest.mark.integration
  202. async def test_both_mode_keeps_buttons_and_records_message(
  203. self, db_session, printer_factory, archive_factory, telegram_provider
  204. ):
  205. printer = await printer_factory()
  206. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="live-token")
  207. provider = await telegram_provider(telegram_verdict_mode="both")
  208. client = await self._send(provider, db_session, archive.id, image_data=b"\x89PNG")
  209. call = client.calls[0]
  210. assert call["url"].endswith("/sendPhoto")
  211. assert "inline_keyboard" in call["data"]["reply_markup"]
  212. row = await db_session.scalar(select(TelegramPendingVerdict))
  213. assert row is not None and row.has_caption is True
  214. @pytest.mark.asyncio
  215. @pytest.mark.integration
  216. async def test_buttons_mode_is_unchanged(self, db_session, printer_factory, archive_factory, telegram_provider):
  217. printer = await printer_factory()
  218. archive = await archive_factory(printer.id, confirm_requested=True)
  219. provider = await telegram_provider(telegram_verdict_mode="buttons")
  220. client = await self._send(provider, db_session, archive.id)
  221. body = client.calls[0]["json"]
  222. assert body["reply_markup"] == {
  223. "inline_keyboard": [
  224. [
  225. {"text": f"{THUMBS_UP} Good", "url": one_tap_url(CONFIRM_VARIABLES["good_url"])},
  226. {"text": f"{THUMBS_DOWN} Reject", "url": one_tap_url(CONFIRM_VARIABLES["reject_url"])},
  227. ]
  228. ]
  229. }
  230. assert TELEGRAM_REACTION_HINT not in body["text"]
  231. assert await _pending_count(db_session) == 0
  232. @pytest.mark.asyncio
  233. @pytest.mark.integration
  234. async def test_prompt_without_a_live_token_records_nothing(
  235. self, db_session, printer_factory, archive_factory, telegram_provider
  236. ):
  237. """The prompt's links are dead without a live token, and a reaction
  238. must not outlive them: nothing to remember."""
  239. printer = await printer_factory()
  240. archive = await archive_factory(
  241. printer.id, confirm_requested=True, confirm_token="spent", confirm_token_used_at=utcnow_naive()
  242. )
  243. provider = await telegram_provider(telegram_verdict_mode="reactions")
  244. await self._send(provider, db_session, archive.id)
  245. assert await _pending_count(db_session) == 0
  246. @pytest.mark.asyncio
  247. @pytest.mark.integration
  248. async def test_missing_message_id_records_nothing(
  249. self, db_session, printer_factory, archive_factory, telegram_provider
  250. ):
  251. printer = await printer_factory()
  252. archive = await archive_factory(printer.id, confirm_requested=True)
  253. provider = await telegram_provider(telegram_verdict_mode="reactions")
  254. service = NotificationService()
  255. client = _SendCapture(message_id=0)
  256. service._http_client = client
  257. ok, _ = await service._send_to_provider(
  258. provider,
  259. "Outcome?",
  260. "How did it go",
  261. db_session,
  262. event_type="print_confirm_request",
  263. variables={**CONFIRM_VARIABLES, "archive_id": archive.id},
  264. )
  265. assert ok
  266. assert await _pending_count(db_session) == 0
  267. # ---------------------------------------------------------------------------
  268. # Poller
  269. # ---------------------------------------------------------------------------
  270. class TestVerdictFromReaction:
  271. def test_thumbs_map_and_others_ignored(self):
  272. assert verdict_from_reaction([{"type": "emoji", "emoji": THUMBS_UP}]) == "good"
  273. assert verdict_from_reaction([{"type": "emoji", "emoji": THUMBS_DOWN}]) == "reject"
  274. assert verdict_from_reaction([{"type": "emoji", "emoji": "❤"}]) is None
  275. assert verdict_from_reaction([{"type": "custom_emoji", "custom_emoji_id": "1"}]) is None
  276. assert verdict_from_reaction([]) is None
  277. assert verdict_from_reaction([{"type": "emoji", "emoji": "❤"}, {"type": "emoji", "emoji": THUMBS_UP}]) == "good"
  278. class TestPoller:
  279. @pytest.mark.asyncio
  280. @pytest.mark.integration
  281. async def test_thumbs_up_marks_good_deletes_row_and_edits_message(
  282. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  283. ):
  284. printer = await printer_factory()
  285. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="tok")
  286. provider = await telegram_provider()
  287. _track(poller, provider)
  288. await pending_factory(provider.id, archive.id, message_id=777)
  289. poller._http_client = _FakeBotApi([_updates(_reaction(10, 777, THUMBS_UP))])
  290. assert await poller.poll_once(BOT_TOKEN) == "ok"
  291. await db_session.refresh(archive)
  292. assert archive.user_verdict == "good"
  293. assert archive.user_verdict_source == "reaction"
  294. assert archive.confirm_token_used_at is not None
  295. entry = await db_session.scalar(
  296. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc())
  297. )
  298. assert entry.user_verdict == "good"
  299. assert await _pending_count(db_session) == 0
  300. assert poller._http_client.urls() == ["getUpdates", "editMessageText"]
  301. edit = poller._http_client.calls[1]["json"]
  302. assert edit["chat_id"] == CHAT_ID and edit["message_id"] == 777
  303. assert edit["text"].endswith("✅ marked as good")
  304. assert "Test\\_Print" in edit["text"], "stored body is re-escaped for Markdown"
  305. assert "reply_markup" not in edit
  306. assert edit["disable_web_page_preview"] is True, "the edit keeps the preview off, like the send"
  307. # The getUpdates that follows confirms the update we handled.
  308. assert poller._offsets[BOT_TOKEN] == 10
  309. @pytest.mark.asyncio
  310. @pytest.mark.integration
  311. async def test_getupdates_request_shape(self, poller, telegram_provider):
  312. provider = await telegram_provider()
  313. _track(poller, provider)
  314. poller._offsets[BOT_TOKEN] = 41
  315. await poller.poll_once(BOT_TOKEN)
  316. call = poller._http_client.calls[0]
  317. assert call["url"] == f"https://api.telegram.org/bot{BOT_TOKEN}/getUpdates"
  318. assert call["json"] == {"timeout": 50, "allowed_updates": ["message_reaction"], "offset": 42}
  319. @pytest.mark.asyncio
  320. @pytest.mark.integration
  321. async def test_offset_belongs_to_the_bot_not_the_provider(self, poller, telegram_provider):
  322. """update_ids are a per-bot sequence: after a provider is re-pointed at
  323. another bot, the first getUpdates for it must not carry the old bot's
  324. offset, or the new bot's lower ids are confirmed away unseen."""
  325. provider = await telegram_provider()
  326. _track(poller, provider)
  327. poller._offsets[BOT_TOKEN] = 900_000_123
  328. _track(poller, provider, token="999:newtoken")
  329. await poller.poll_once("999:newtoken")
  330. call = poller._http_client.calls[0]
  331. assert call["url"].startswith("https://api.telegram.org/bot999:newtoken/")
  332. assert "offset" not in call["json"]
  333. @pytest.mark.asyncio
  334. @pytest.mark.integration
  335. async def test_thumbs_down_rejects(
  336. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  337. ):
  338. printer = await printer_factory()
  339. archive = await archive_factory(printer.id, confirm_requested=True)
  340. provider = await telegram_provider()
  341. _track(poller, provider)
  342. await pending_factory(provider.id, archive.id, message_id=778)
  343. poller._http_client = _FakeBotApi([_updates(_reaction(11, 778, THUMBS_DOWN))])
  344. await poller.poll_once(BOT_TOKEN)
  345. await db_session.refresh(archive)
  346. assert archive.user_verdict == "reject"
  347. assert poller._http_client.calls[1]["json"]["text"].endswith("❌ marked as reject")
  348. @pytest.mark.asyncio
  349. @pytest.mark.integration
  350. async def test_caption_message_is_edited_with_editMessageCaption(
  351. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  352. ):
  353. printer = await printer_factory()
  354. archive = await archive_factory(printer.id, confirm_requested=True)
  355. provider = await telegram_provider()
  356. _track(poller, provider)
  357. await pending_factory(provider.id, archive.id, message_id=779, has_caption=True)
  358. poller._http_client = _FakeBotApi([_updates(_reaction(12, 779, THUMBS_UP))])
  359. await poller.poll_once(BOT_TOKEN)
  360. assert poller._http_client.urls() == ["getUpdates", "editMessageCaption"]
  361. assert poller._http_client.calls[1]["json"]["caption"].endswith("✅ marked as good")
  362. @pytest.mark.asyncio
  363. @pytest.mark.integration
  364. async def test_unknown_message_is_ignored(
  365. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  366. ):
  367. printer = await printer_factory()
  368. archive = await archive_factory(printer.id, confirm_requested=True)
  369. provider = await telegram_provider()
  370. _track(poller, provider)
  371. await pending_factory(provider.id, archive.id, message_id=780)
  372. poller._http_client = _FakeBotApi(
  373. [_updates(_reaction(13, 999, THUMBS_UP), _reaction(14, 780, THUMBS_UP, chat_id="-100999"))]
  374. )
  375. await poller.poll_once(BOT_TOKEN)
  376. await db_session.refresh(archive)
  377. assert archive.user_verdict is None
  378. assert await _pending_count(db_session) == 1
  379. assert poller._http_client.urls() == ["getUpdates"]
  380. assert poller._offsets[BOT_TOKEN] == 14
  381. @pytest.mark.asyncio
  382. @pytest.mark.integration
  383. async def test_other_emoji_and_removed_reaction_are_ignored(
  384. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  385. ):
  386. printer = await printer_factory()
  387. archive = await archive_factory(printer.id, confirm_requested=True)
  388. provider = await telegram_provider()
  389. _track(poller, provider)
  390. await pending_factory(provider.id, archive.id, message_id=781)
  391. poller._http_client = _FakeBotApi([_updates(_reaction(15, 781, "❤"), _reaction(16, 781, None))])
  392. await poller.poll_once(BOT_TOKEN)
  393. await db_session.refresh(archive)
  394. assert archive.user_verdict is None
  395. assert await _pending_count(db_session) == 1, "the prompt stays open for a later thumbs"
  396. @pytest.mark.asyncio
  397. @pytest.mark.integration
  398. async def test_reaction_after_a_verdict_is_a_noop(
  399. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  400. ):
  401. """Someone already answered in the web UI: the late reaction neither
  402. flips the verdict nor edits the message, but the row is retired."""
  403. printer = await printer_factory()
  404. archive = await archive_factory(printer.id, confirm_requested=True, user_verdict="reject")
  405. provider = await telegram_provider()
  406. _track(poller, provider)
  407. await pending_factory(provider.id, archive.id, message_id=782)
  408. poller._http_client = _FakeBotApi([_updates(_reaction(17, 782, THUMBS_UP))])
  409. await poller.poll_once(BOT_TOKEN)
  410. await db_session.refresh(archive)
  411. assert archive.user_verdict == "reject"
  412. assert await _pending_count(db_session) == 0
  413. assert poller._http_client.urls() == ["getUpdates"]
  414. @pytest.mark.asyncio
  415. @pytest.mark.integration
  416. async def test_an_earlier_runs_prompt_cannot_answer_a_reprint(
  417. self, poller, db_session, printer_factory, archive_factory, telegram_provider, monkeypatch
  418. ):
  419. """Printing an archive again resets its verdict and confirm token, so
  420. run 1's links stop working. Run 1's unanswered message has to stop with
  421. them: a thumbs on it while run 2 prints records nothing, and run 2
  422. still asks its own question, which its own message then answers."""
  423. from backend.app.main import dispatch_outcome_confirmation
  424. printer = await printer_factory()
  425. archive = await archive_factory(printer.id, confirm_requested=True)
  426. provider = await telegram_provider()
  427. _track(poller, provider)
  428. pending_columns = select(TelegramPendingVerdict.message_id, TelegramPendingVerdict.confirm_token)
  429. # Run 1 completes and asks; nobody answers.
  430. monkeypatch.setattr(notification_service, "_http_client", _SendCapture(message_id=901))
  431. assert await dispatch_outcome_confirmation(db_session, printer.id, printer.name, {}, archive.id, None)
  432. run1_token = archive.confirm_token
  433. assert (await db_session.execute(pending_columns)).one() == (901, run1_token)
  434. # Run 2 starts: the reprint reset in main.py (the expected_archive_id path).
  435. archive.status = "printing"
  436. archive.user_verdict = None
  437. archive.user_verdict_source = None
  438. archive.user_verdict_at = None
  439. archive.confirm_token = None
  440. archive.confirm_token_used_at = None
  441. await db_session.commit()
  442. # A thumbs-up on run 1's message while run 2 is printing.
  443. poller._http_client = _FakeBotApi([_updates(_reaction(30, 901, THUMBS_UP))])
  444. await poller.poll_once(BOT_TOKEN)
  445. await db_session.refresh(archive)
  446. assert archive.user_verdict is None
  447. assert poller._http_client.urls() == ["getUpdates"], "the stale message is not edited"
  448. assert await _pending_count(db_session) == 0
  449. # Run 2 completes and asks with a token of its own.
  450. archive.status = "completed"
  451. await db_session.commit()
  452. monkeypatch.setattr(notification_service, "_http_client", _SendCapture(message_id=902))
  453. assert await dispatch_outcome_confirmation(db_session, printer.id, printer.name, {}, archive.id, None)
  454. assert archive.confirm_token not in (None, run1_token)
  455. assert (await db_session.execute(pending_columns)).one() == (902, archive.confirm_token)
  456. # Its own message answers it.
  457. poller._http_client = _FakeBotApi([_updates(_reaction(31, 902, THUMBS_DOWN))])
  458. await poller.poll_once(BOT_TOKEN)
  459. await db_session.refresh(archive)
  460. assert archive.user_verdict == "reject"
  461. assert archive.user_verdict_source == "reaction"
  462. @pytest.mark.asyncio
  463. @pytest.mark.integration
  464. async def test_a_prompt_whose_link_was_used_is_retired(
  465. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  466. ):
  467. """A reaction stops working exactly when a link would: once the token
  468. is spent, or replaced by a newer prompt's, the message is done."""
  469. printer = await printer_factory()
  470. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="old")
  471. provider = await telegram_provider()
  472. _track(poller, provider)
  473. await pending_factory(provider.id, archive.id, message_id=785)
  474. archive.confirm_token = "newer"
  475. await db_session.commit()
  476. poller._http_client = _FakeBotApi([_updates(_reaction(32, 785, THUMBS_UP))])
  477. await poller.poll_once(BOT_TOKEN)
  478. await db_session.refresh(archive)
  479. assert archive.user_verdict is None
  480. assert await _pending_count(db_session) == 0
  481. assert poller._http_client.urls() == ["getUpdates"]
  482. @pytest.mark.asyncio
  483. @pytest.mark.integration
  484. async def test_second_reaction_on_same_message_is_a_noop(
  485. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  486. ):
  487. printer = await printer_factory()
  488. archive = await archive_factory(printer.id, confirm_requested=True)
  489. provider = await telegram_provider()
  490. _track(poller, provider)
  491. await pending_factory(provider.id, archive.id, message_id=783)
  492. poller._http_client = _FakeBotApi(
  493. [_updates(_reaction(18, 783, THUMBS_UP)), _updates(_reaction(19, 783, THUMBS_DOWN))]
  494. )
  495. await poller.poll_once(BOT_TOKEN)
  496. await poller.poll_once(BOT_TOKEN)
  497. await db_session.refresh(archive)
  498. assert archive.user_verdict == "good"
  499. assert poller._http_client.urls() == ["getUpdates", "editMessageText", "getUpdates"]
  500. @pytest.mark.asyncio
  501. @pytest.mark.integration
  502. async def test_failed_edit_does_not_lose_the_verdict(
  503. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  504. ):
  505. printer = await printer_factory()
  506. archive = await archive_factory(printer.id, confirm_requested=True)
  507. provider = await telegram_provider()
  508. _track(poller, provider)
  509. await pending_factory(provider.id, archive.id, message_id=784)
  510. poller._http_client = _FakeBotApi(
  511. [_updates(_reaction(20, 784, THUMBS_UP)), httpx.ConnectError("telegram gone")]
  512. )
  513. assert await poller.poll_once(BOT_TOKEN) == "ok"
  514. await db_session.refresh(archive)
  515. assert archive.user_verdict == "good"
  516. assert await _pending_count(db_session) == 0
  517. @pytest.mark.asyncio
  518. @pytest.mark.integration
  519. async def test_conflict_is_surfaced_and_cooled_down(self, poller, db_session, telegram_provider):
  520. """409 = webhook set or a second poller: report it on the provider and
  521. wait the cooldown instead of looping on the next getUpdates."""
  522. provider = await telegram_provider()
  523. _track(poller, provider)
  524. conflict = httpx.Response(
  525. 409,
  526. json={"ok": False, "error_code": 409, "description": "Conflict: terminated by other getUpdates request"},
  527. )
  528. poller._http_client = _FakeBotApi([conflict, conflict])
  529. assert await poller.poll_once(BOT_TOKEN) == "conflict"
  530. await db_session.refresh(provider)
  531. assert "Conflict: terminated by other getUpdates request" in provider.last_error
  532. assert "webhook" in provider.last_error
  533. assert provider.last_error_at is not None
  534. sleeps: list[float] = []
  535. async def _sleep(seconds):
  536. sleeps.append(seconds)
  537. raise CancelledForTest
  538. poller._sleep = _sleep
  539. with pytest.raises(CancelledForTest):
  540. await poller._run(BOT_TOKEN)
  541. assert sleeps == [CONFLICT_COOLDOWN]
  542. assert len(poller._http_client.calls) == 2, "one getUpdates per cooldown, not a tight loop"
  543. @pytest.mark.asyncio
  544. @pytest.mark.integration
  545. async def test_bad_bot_token_is_surfaced_and_cooled_down(self, poller, db_session, telegram_provider):
  546. """401/404 = wrong or deleted bot token: it never fixes itself, so it
  547. lands on the provider card and waits the cooldown instead of a warning
  548. every minute forever."""
  549. provider = await telegram_provider()
  550. _track(poller, provider)
  551. unauthorized = httpx.Response(401, json={"ok": False, "error_code": 401, "description": "Unauthorized"})
  552. poller._http_client = _FakeBotApi([unauthorized, unauthorized])
  553. assert await poller.poll_once(BOT_TOKEN) == "rejected"
  554. await db_session.refresh(provider)
  555. assert "HTTP 401" in provider.last_error
  556. assert "Unauthorized" in provider.last_error
  557. assert "bot token" in provider.last_error
  558. assert provider.last_error_at is not None
  559. sleeps: list[float] = []
  560. async def _sleep(seconds):
  561. sleeps.append(seconds)
  562. raise CancelledForTest
  563. poller._sleep = _sleep
  564. with pytest.raises(CancelledForTest):
  565. await poller._run(BOT_TOKEN)
  566. assert sleeps == [CONFLICT_COOLDOWN]
  567. assert len(poller._http_client.calls) == 2
  568. @pytest.mark.asyncio
  569. @pytest.mark.integration
  570. async def test_conflict_is_recorded_on_every_provider_of_the_bot(self, poller, db_session, telegram_provider):
  571. first = await telegram_provider(name="X1C")
  572. second = await telegram_provider(name="P1S")
  573. _track(poller, first, second)
  574. conflict = httpx.Response(409, json={"ok": False, "error_code": 409, "description": "Conflict"})
  575. poller._http_client = _FakeBotApi([conflict])
  576. assert await poller.poll_once(BOT_TOKEN) == "conflict"
  577. for provider in (first, second):
  578. await db_session.refresh(provider)
  579. assert provider.last_error and "Conflict" in provider.last_error
  580. @pytest.mark.asyncio
  581. @pytest.mark.integration
  582. async def test_network_errors_back_off_exponentially(self, poller, telegram_provider):
  583. provider = await telegram_provider()
  584. _track(poller, provider)
  585. poller._http_client = _FakeBotApi([httpx.ConnectError("down")] * 10)
  586. sleeps: list[float] = []
  587. async def _sleep(seconds):
  588. sleeps.append(seconds)
  589. if len(sleeps) == 8:
  590. raise CancelledForTest
  591. poller._sleep = _sleep
  592. with pytest.raises(CancelledForTest):
  593. await poller._run(BOT_TOKEN)
  594. assert sleeps == [1, 2, 4, 8, 16, 32, 60, 60]
  595. assert max(sleeps) == MAX_BACKOFF
  596. @pytest.mark.asyncio
  597. @pytest.mark.integration
  598. async def test_prune_drops_rows_older_than_a_week(
  599. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  600. ):
  601. printer = await printer_factory()
  602. archive = await archive_factory(printer.id, confirm_requested=True)
  603. provider = await telegram_provider()
  604. _track(poller, provider)
  605. await pending_factory(provider.id, archive.id, message_id=1, created_at=utcnow_naive() - timedelta(days=8))
  606. fresh = await pending_factory(provider.id, archive.id, message_id=2)
  607. await poller.prune_stale()
  608. rows = (await db_session.execute(select(TelegramPendingVerdict))).scalars().all()
  609. assert [r.id for r in rows] == [fresh.id]
  610. @pytest.mark.asyncio
  611. @pytest.mark.integration
  612. async def test_sync_follows_provider_mode_enabled_and_token(self, poller, db_session, telegram_provider):
  613. never = _NeverAnswers()
  614. poller._http_client = never
  615. reactions = await telegram_provider(name="R", telegram_verdict_mode="reactions")
  616. buttons = await telegram_provider(name="B", telegram_verdict_mode="buttons")
  617. disabled = await telegram_provider(name="D", telegram_verdict_mode="both", enabled=False)
  618. await poller.sync()
  619. assert _polling(poller, reactions.id)
  620. assert not _polling(poller, buttons.id)
  621. assert not _polling(poller, disabled.id)
  622. assert set(poller._tasks) == {BOT_TOKEN}
  623. # The bot keeps its single poll while any provider wants it; the
  624. # provider set behind it follows the edits.
  625. first_task = poller._tasks[BOT_TOKEN]
  626. reactions.telegram_verdict_mode = "buttons"
  627. buttons.telegram_verdict_mode = "both"
  628. await db_session.commit()
  629. await poller.sync()
  630. assert not _polling(poller, reactions.id)
  631. assert _polling(poller, buttons.id)
  632. assert poller._tasks[BOT_TOKEN] is first_task
  633. assert poller._providers[BOT_TOKEN] == {buttons.id}
  634. # A token change moves the provider to a new bot: the old bot's poll
  635. # stops because nobody uses it any more, a fresh one starts.
  636. buttons.config = '{"bot_token": "999:newtoken", "chat_id": "1"}'
  637. await db_session.commit()
  638. await poller.sync()
  639. assert first_task.cancelled() or first_task.cancelling()
  640. assert set(poller._tasks) == {"999:newtoken"}
  641. assert _polling(poller, buttons.id)
  642. await poller.aclose()
  643. assert poller._tasks == {}
  644. assert never.is_closed
  645. @pytest.mark.asyncio
  646. @pytest.mark.integration
  647. async def test_providers_sharing_a_bot_share_one_poll(
  648. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  649. ):
  650. """The usual farm layout: one bot, one provider per printer. Telegram
  651. serves a single getUpdates consumer per bot, so both providers ride
  652. one task and a reaction to either prompt lands on its archive."""
  653. poller._http_client = _NeverAnswers()
  654. x1c = await printer_factory(name="X1C")
  655. p1s = await printer_factory(name="P1S")
  656. first = await telegram_provider(name="X1C", printer_id=x1c.id, config={"bot_token": BOT_TOKEN, "chat_id": "1"})
  657. second = await telegram_provider(name="P1S", printer_id=p1s.id, config={"bot_token": BOT_TOKEN, "chat_id": "2"})
  658. await poller.sync()
  659. assert len(poller._tasks) == 1
  660. assert _polling(poller, first.id) and _polling(poller, second.id)
  661. poller.stop()
  662. _track(poller, first, second)
  663. archive_a = await archive_factory(x1c.id, confirm_requested=True)
  664. archive_b = await archive_factory(p1s.id, confirm_requested=True)
  665. await pending_factory(first.id, archive_a.id, message_id=100, chat_id="1")
  666. await pending_factory(second.id, archive_b.id, message_id=100, chat_id="2")
  667. poller._http_client = _FakeBotApi(
  668. [_updates(_reaction(30, 100, THUMBS_DOWN, chat_id="2"), _reaction(31, 100, THUMBS_UP, chat_id="1"))]
  669. )
  670. await poller.poll_once(BOT_TOKEN)
  671. await db_session.refresh(archive_a)
  672. await db_session.refresh(archive_b)
  673. assert archive_a.user_verdict == "good"
  674. assert archive_b.user_verdict == "reject"
  675. assert await _pending_count(db_session) == 0
  676. assert poller._http_client.urls() == ["getUpdates", "editMessageText", "editMessageText"]
  677. @pytest.mark.asyncio
  678. @pytest.mark.integration
  679. async def test_reaction_for_another_bots_provider_is_not_matched(
  680. self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
  681. ):
  682. """Two bots in private chat with the same person number their
  683. messages independently, so a match is scoped to the bot's providers."""
  684. printer = await printer_factory()
  685. archive = await archive_factory(printer.id, confirm_requested=True)
  686. other = await telegram_provider(name="other bot", config={"bot_token": "999:other", "chat_id": CHAT_ID})
  687. mine = await telegram_provider(name="mine")
  688. _track(poller, mine)
  689. _track(poller, other, token="999:other")
  690. await pending_factory(other.id, archive.id, message_id=5)
  691. poller._http_client = _FakeBotApi([_updates(_reaction(40, 5, THUMBS_UP))])
  692. await poller.poll_once(BOT_TOKEN)
  693. await db_session.refresh(archive)
  694. assert archive.user_verdict is None
  695. assert await _pending_count(db_session) == 1
  696. class CancelledForTest(BaseException):
  697. """Escape hatch out of the endless poll loop without tripping its handlers."""
  698. class _NeverAnswers:
  699. """getUpdates that stays open forever, like a quiet chat would."""
  700. def __init__(self):
  701. self.is_closed = False
  702. async def post(self, url, json=None, data=None, files=None):
  703. import asyncio
  704. await asyncio.Event().wait()
  705. async def aclose(self):
  706. self.is_closed = True
  707. # ---------------------------------------------------------------------------
  708. # Provider API round trip
  709. # ---------------------------------------------------------------------------
  710. class TestProviderApi:
  711. @pytest.mark.asyncio
  712. @pytest.mark.integration
  713. async def test_mode_round_trips_and_defaults_to_buttons(self, async_client: AsyncClient):
  714. with patch("backend.app.api.routes.notifications.telegram_reaction_poller.sync", new=AsyncMock()) as sync:
  715. created = await async_client.post(
  716. "/api/v1/notifications/",
  717. json={
  718. "name": "TG",
  719. "provider_type": "telegram",
  720. "config": TELEGRAM_CONFIG,
  721. "telegram_verdict_mode": "reactions",
  722. },
  723. )
  724. assert created.status_code == 200, created.text
  725. assert created.json()["telegram_verdict_mode"] == "reactions"
  726. provider_id = created.json()["id"]
  727. assert sync.await_count == 1
  728. listed = await async_client.get("/api/v1/notifications/")
  729. assert [p["telegram_verdict_mode"] for p in listed.json() if p["id"] == provider_id] == ["reactions"]
  730. patched = await async_client.patch(
  731. f"/api/v1/notifications/{provider_id}", json={"telegram_verdict_mode": "both"}
  732. )
  733. assert patched.status_code == 200
  734. assert patched.json()["telegram_verdict_mode"] == "both"
  735. assert sync.await_count == 2
  736. invalid = await async_client.patch(
  737. f"/api/v1/notifications/{provider_id}", json={"telegram_verdict_mode": "webhook"}
  738. )
  739. assert invalid.status_code == 422
  740. other = await async_client.post(
  741. "/api/v1/notifications/",
  742. json={"name": "ntfy", "provider_type": "ntfy", "config": {"topic": "t"}},
  743. )
  744. assert other.json()["telegram_verdict_mode"] == "buttons"
  745. deleted = await async_client.delete(f"/api/v1/notifications/{provider_id}")
  746. assert deleted.status_code == 200
  747. assert sync.await_count == 4
  748. @pytest.mark.asyncio
  749. @pytest.mark.integration
  750. async def test_legacy_null_mode_reads_as_buttons(
  751. self, async_client: AsyncClient, notification_provider_factory, db_session
  752. ):
  753. provider = await notification_provider_factory(name="Legacy")
  754. # The ORM default fills a None at INSERT; a pre-#3046 row holds a real NULL.
  755. await db_session.execute(
  756. text("UPDATE notification_providers SET telegram_verdict_mode = NULL WHERE id = :id"), {"id": provider.id}
  757. )
  758. await db_session.commit()
  759. stored = await db_session.scalar(
  760. text("SELECT telegram_verdict_mode FROM notification_providers WHERE id = :id"), {"id": provider.id}
  761. )
  762. assert stored is None, "row under test must actually hold NULL"
  763. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  764. assert response.status_code == 200
  765. assert response.json()["telegram_verdict_mode"] == "buttons"
  766. @pytest.mark.asyncio
  767. @pytest.mark.integration
  768. async def test_deleting_a_provider_cascades_its_pending_rows(
  769. self,
  770. async_client: AsyncClient,
  771. db_session,
  772. printer_factory,
  773. archive_factory,
  774. telegram_provider,
  775. pending_factory,
  776. ):
  777. printer = await printer_factory()
  778. archive = await archive_factory(printer.id, confirm_requested=True)
  779. provider = await telegram_provider()
  780. await pending_factory(provider.id, archive.id)
  781. with patch("backend.app.api.routes.notifications.telegram_reaction_poller.sync", new=AsyncMock()):
  782. assert (await async_client.delete(f"/api/v1/notifications/{provider.id}")).status_code == 200
  783. assert await _pending_count(db_session) == 0