test_giveaway_recurring_eligibility.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. from __future__ import annotations
  2. from datetime import UTC, datetime, timedelta
  3. from types import SimpleNamespace
  4. import pytest
  5. from aiohttp import CookieJar
  6. from aiohttp.test_utils import TestClient, TestServer
  7. from pyrogram.enums import ChatMemberStatus
  8. def _member(status, *, is_member=True):
  9. return SimpleNamespace(status=status, is_member=is_member, privileges=None)
  10. async def test_eligibility_all_any_and_unknown(app_modules):
  11. eligibility = app_modules.load("wbb.services.giveaway_eligibility")
  12. giveaway = {
  13. "chat_id": -100,
  14. "eligibility_targets": [{"chat_id": -200}, {"chat_id": -300}],
  15. "eligibility_mode": "all",
  16. }
  17. app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
  18. app_modules.app.members[(-300, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
  19. app_modules.app.members[(-200, 10)] = _member(ChatMemberStatus.MEMBER)
  20. app_modules.app.members[(-300, 10)] = _member(ChatMemberStatus.LEFT)
  21. assert await eligibility.check_eligibility(giveaway, 10) == (False, [-300])
  22. giveaway["eligibility_mode"] = "any"
  23. assert await eligibility.check_eligibility(giveaway, 10) == (True, [])
  24. app_modules.app.members[(-200, 10)] = _member(ChatMemberStatus.RESTRICTED, is_member=False)
  25. assert await eligibility.check_eligibility(giveaway, 10) == (False, [-200, -300])
  26. app_modules.app.members[(-300, 999)] = _member(ChatMemberStatus.MEMBER)
  27. with pytest.raises(eligibility.EligibilityUnavailable):
  28. await eligibility.check_eligibility(giveaway, 10)
  29. async def test_paid_tickets_idempotent_cap_weight_and_cancel_refund(app_modules):
  30. points = app_modules.load("wbb.utils.dbpoints")
  31. database = app_modules.load("wbb.utils.dbgiveaway")
  32. service = app_modules.load("wbb.services.giveaways")
  33. await points.adjust_points(
  34. chat_id=-100, user_id=10, delta=100, source=points.SOURCE_ADMIN,
  35. idempotency_key="seed:10", reason="seed",
  36. )
  37. giveaway = await database.create_giveaway(
  38. chat_id=-100, creator_id=1, creator_name="Admin", title="Tickets",
  39. description="", prizes=[{"name": "Winner", "count": 1}],
  40. ends_at=datetime.now(UTC) + timedelta(hours=1),
  41. entry_cost=10, max_tickets_per_user=5,
  42. )
  43. giveaway_id = giveaway["giveaway_id"]
  44. status, _ = await service.join_giveaway(
  45. giveaway_id, user_id=10, username=None, first_name="Alice"
  46. )
  47. assert status == "ok"
  48. status, _ = await service.buy_additional_tickets(
  49. giveaway_id, user_id=10, quantity=4, request_id="callback-1"
  50. )
  51. assert status == "ok"
  52. replay, _ = await service.buy_additional_tickets(
  53. giveaway_id, user_id=10, quantity=4, request_id="callback-1"
  54. )
  55. capped, _ = await service.buy_additional_tickets(
  56. giveaway_id, user_id=10, quantity=1, request_id="callback-2"
  57. )
  58. assert (replay, capped) == ("duplicate", "ticket_limit")
  59. participant = await database.get_participant(giveaway_id, 10)
  60. assert participant["paid_ticket_count"] == 5
  61. assert (await points.get_point_account(-100, 10))["balance"] == 50
  62. assert len(service.pick_winners([participant], giveaway["prizes"])) == 1
  63. canceled, _, _ = await service.cancel_and_refund_giveaway(
  64. giveaway_id, chat_id=-100, publish=False
  65. )
  66. assert canceled is True
  67. assert (await points.get_point_account(-100, 10))["balance"] == 100
  68. async def test_first_paid_purchase_can_buy_five_tickets_atomically(app_modules):
  69. points = app_modules.load("wbb.utils.dbpoints")
  70. database = app_modules.load("wbb.utils.dbgiveaway")
  71. service = app_modules.load("wbb.services.giveaways")
  72. await points.adjust_points(
  73. chat_id=-100, user_id=10, delta=100, source=points.SOURCE_ADMIN,
  74. idempotency_key="seed:first-five", reason="seed",
  75. )
  76. giveaway = await database.create_giveaway(
  77. chat_id=-100, creator_id=1, creator_name="Admin", title="Five",
  78. description="", prizes=[{"name": "Winner", "count": 1}],
  79. ends_at=datetime.now(UTC) + timedelta(hours=1), entry_cost=10,
  80. )
  81. giveaway_id = giveaway["giveaway_id"]
  82. status, _ = await service.buy_additional_tickets(
  83. giveaway_id, user_id=10, quantity=5, request_id="first-purchase"
  84. )
  85. replay, _ = await service.buy_additional_tickets(
  86. giveaway_id, user_id=10, quantity=5, request_id="first-purchase"
  87. )
  88. assert (status, replay) == ("ok", "duplicate")
  89. assert await database.count_participants(giveaway_id) == 1
  90. participant = await database.get_participant(giveaway_id, 10)
  91. assert participant["paid_ticket_count"] == 5
  92. assert participant["pending"] is False
  93. assert (await points.get_point_account(-100, 10))["balance"] == 50
  94. async def test_ticket_purchase_recovers_after_debit_before_ticket_write(app_modules, monkeypatch):
  95. points = app_modules.load("wbb.utils.dbpoints")
  96. database = app_modules.load("wbb.utils.dbgiveaway")
  97. service = app_modules.load("wbb.services.giveaways")
  98. await points.adjust_points(
  99. chat_id=-100, user_id=10, delta=100, source=points.SOURCE_ADMIN,
  100. idempotency_key="seed:recover-ticket", reason="seed",
  101. )
  102. giveaway = await database.create_giveaway(
  103. chat_id=-100, creator_id=1, creator_name="Admin", title="Recover",
  104. description="", prizes=[{"name": "Winner", "count": 1}],
  105. ends_at=datetime.now(UTC) + timedelta(hours=1), entry_cost=10,
  106. )
  107. original_adjust = service.adjust_points
  108. interrupted = False
  109. async def interrupt_after_debit(**kwargs):
  110. nonlocal interrupted
  111. result = await original_adjust(**kwargs)
  112. if kwargs["delta"] < 0 and not interrupted:
  113. interrupted = True
  114. raise RuntimeError("simulated restart after debit")
  115. return result
  116. monkeypatch.setattr(service, "adjust_points", interrupt_after_debit)
  117. with pytest.raises(RuntimeError, match="simulated restart"):
  118. await service.buy_additional_tickets(
  119. giveaway["giveaway_id"], user_id=10, quantity=5,
  120. request_id="recover-ticket",
  121. )
  122. assert (await points.get_point_account(-100, 10))["balance"] == 50
  123. monkeypatch.setattr(service, "adjust_points", original_adjust)
  124. await service.reconcile_pending_ticket_orders()
  125. participant = await database.get_participant(giveaway["giveaway_id"], 10)
  126. assert participant["paid_ticket_count"] == 5
  127. assert participant["pending"] is False
  128. assert (await points.get_point_account(-100, 10))["balance"] == 50
  129. async def test_draw_waits_for_membership_recovery_and_excludes_confirmed_leaver(app_modules):
  130. points = app_modules.load("wbb.utils.dbpoints")
  131. database = app_modules.load("wbb.utils.dbgiveaway")
  132. service = app_modules.load("wbb.services.giveaways")
  133. for user_id in (10, 11):
  134. await points.adjust_points(
  135. chat_id=-100, user_id=user_id, delta=20, source=points.SOURCE_ADMIN,
  136. idempotency_key=f"seed:eligibility:{user_id}", reason="seed",
  137. )
  138. giveaway = await database.create_giveaway(
  139. chat_id=-100, creator_id=1, creator_name="Admin", title="Membership",
  140. description="", prizes=[{"name": "Winner", "count": 1}],
  141. ends_at=datetime.now(UTC) + timedelta(hours=1), entry_cost=5,
  142. participation_reward=2,
  143. eligibility_targets=[{"chat_id": -200}],
  144. )
  145. app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
  146. for user_id in (10, 11):
  147. status, _ = await service.join_giveaway(
  148. giveaway["giveaway_id"], user_id=user_id,
  149. username=None, first_name=str(user_id),
  150. )
  151. assert status == "ok"
  152. app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.MEMBER)
  153. with pytest.raises(service.GiveawayServiceError, match="资格核验暂不可用"):
  154. await service.finish_and_publish_giveaway(giveaway["giveaway_id"], publish=False)
  155. paused = await database.get_giveaway(giveaway["giveaway_id"])
  156. assert paused["status"] == database.STATUS_DRAWING
  157. assert paused["eligibility_error"]
  158. app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
  159. app_modules.app.members[(-200, 11)] = _member(ChatMemberStatus.LEFT)
  160. completed, _, current = await service.finish_and_publish_giveaway(
  161. giveaway["giveaway_id"], publish=False
  162. )
  163. assert completed is True
  164. assert [winner["user_id"] for winner in current["winners"]] == [10]
  165. leaver = await database.get_participant(giveaway["giveaway_id"], 11)
  166. assert leaver["eligibility_status"] == "ineligible"
  167. assert (await points.get_point_account(-100, 11))["balance"] == 15
  168. async def test_paused_preselection_draw_can_be_canceled(app_modules):
  169. database = app_modules.load("wbb.utils.dbgiveaway")
  170. service = app_modules.load("wbb.services.giveaways")
  171. giveaway = await database.create_giveaway(
  172. chat_id=-100, creator_id=1, creator_name="Admin", title="Paused",
  173. description="", prizes=[{"name": "First", "count": 1}],
  174. ends_at=datetime.now(UTC) + timedelta(hours=1),
  175. )
  176. await database.claim_giveaway_draw(giveaway["giveaway_id"])
  177. canceled, _, current = await service.cancel_and_refund_giveaway(
  178. giveaway["giveaway_id"], chat_id=-100, publish=False
  179. )
  180. assert canceled is True
  181. assert current["status"] == database.STATUS_CANCELED
  182. async def test_month_end_schedule_and_materialization_is_idempotent(app_modules, monkeypatch):
  183. templates = app_modules.load("wbb.services.giveaway_templates")
  184. database = app_modules.load("wbb.utils.dbgiveaway")
  185. async def unsupported_history(*_args, **_kwargs):
  186. raise AssertionError("Bot accounts must not request chat history")
  187. yield
  188. monkeypatch.setattr(app_modules.app, "get_chat_history", unsupported_history)
  189. body = {
  190. "chat_id": -100, "title": "Monthly", "description": "",
  191. "prizes": [{"name": "First", "count": 1}],
  192. "day_of_month": 31, "start_time": "00:00",
  193. "draw_day_offset": 0, "draw_time": "20:00",
  194. }
  195. start, draw = templates.period_schedule(body, 2024, 2)
  196. assert start.astimezone(templates.TIMEZONE).day == 29
  197. assert draw - start == timedelta(hours=20)
  198. start, draw = templates.period_schedule(body, 2025, 2)
  199. assert start.astimezone(templates.TIMEZONE).day == 28
  200. now = datetime.now(UTC)
  201. local = now.astimezone(templates.TIMEZONE)
  202. body["day_of_month"] = local.day
  203. body["start_time"] = "00:00"
  204. body["draw_day_offset"] = 1
  205. body["draw_time"] = "20:00"
  206. template = await templates.create_template(body, "Admin")
  207. await templates.materialize_template(template, now=now)
  208. await templates.materialize_template(template, now=now)
  209. items = [item async for item in database.giveawaysdb.find({"template_id": template["template_id"]})]
  210. assert len(items) == 1
  211. assert items[0]["period_key"] == local.strftime("%Y-%m")
  212. assert items[0]["message_id"] == 1
  213. assert len(app_modules.app.sent_messages) == 1
  214. async def test_announcement_send_failure_retries_same_period(app_modules, monkeypatch):
  215. templates = app_modules.load("wbb.services.giveaway_templates")
  216. database = app_modules.load("wbb.utils.dbgiveaway")
  217. now = datetime.now(UTC)
  218. local = now.astimezone(templates.TIMEZONE)
  219. template = await templates.create_template({
  220. "chat_id": -100, "title": "Retry", "description": "",
  221. "prizes": [{"name": "First", "count": 1}],
  222. "day_of_month": local.day, "start_time": "00:00",
  223. "draw_day_offset": 1, "draw_time": "20:00",
  224. }, "Admin")
  225. original_send = app_modules.app.send_message
  226. failed_once = False
  227. async def flaky_send(*args, **kwargs):
  228. nonlocal failed_once
  229. if not failed_once:
  230. failed_once = True
  231. raise RuntimeError("temporary Telegram failure")
  232. return await original_send(*args, **kwargs)
  233. monkeypatch.setattr(app_modules.app, "send_message", flaky_send)
  234. with pytest.raises(RuntimeError, match="temporary Telegram failure"):
  235. await templates.materialize_template(template, now=now)
  236. await templates.materialize_template(template, now=now)
  237. items = [item async for item in database.giveawaysdb.find({
  238. "template_id": template["template_id"]
  239. })]
  240. assert len(items) == 1
  241. assert items[0]["message_id"] == 1
  242. assert len(app_modules.app.sent_messages) == 1
  243. async def test_due_unannounced_period_cancels_instead_of_drawing(app_modules):
  244. database = app_modules.load("wbb.utils.dbgiveaway")
  245. service = app_modules.load("wbb.services.giveaways")
  246. giveaway = await database.create_giveaway(
  247. chat_id=-100, creator_id=1, creator_name="Admin", title="Unannounced",
  248. description="", prizes=[{"name": "First", "count": 1}],
  249. ends_at=datetime.now(UTC) + timedelta(hours=1),
  250. template_id="monthly-template", period_key="2026-09",
  251. )
  252. accepted, reason, _ = await service.finish_and_publish_giveaway(
  253. giveaway["giveaway_id"], publish=False
  254. )
  255. assert accepted is False
  256. assert "报名公告未发布" in reason
  257. await database.giveawaysdb.update_one(
  258. {"giveaway_id": giveaway["giveaway_id"]},
  259. {"$set": {"ends_at": datetime.now(UTC) - timedelta(seconds=1)}},
  260. )
  261. due = await database.list_due_giveaways()
  262. await service.resume_pending_giveaway(due[0])
  263. canceled = await database.get_giveaway(giveaway["giveaway_id"])
  264. assert canceled["status"] == database.STATUS_CANCELED
  265. assert canceled["cancellation_reason"] == "announcement_not_published"
  266. assert canceled["winners"] == []
  267. assert not app_modules.app.sent_messages
  268. assert not app_modules.app.pinned_messages
  269. async def test_due_period_recovers_draw_and_pins_result_once(app_modules, monkeypatch):
  270. database = app_modules.load("wbb.utils.dbgiveaway")
  271. service = app_modules.load("wbb.services.giveaways")
  272. async def unsupported_history(*_args, **_kwargs):
  273. raise AssertionError("Bot accounts must not request chat history")
  274. yield
  275. monkeypatch.setattr(app_modules.app, "get_chat_history", unsupported_history)
  276. giveaway = await database.create_giveaway(
  277. chat_id=-100, creator_id=1, creator_name="Admin", title="Catch-up",
  278. description="", prizes=[{"name": "First", "count": 1}],
  279. ends_at=datetime.now(UTC) + timedelta(hours=1),
  280. template_id="monthly-template", period_key="2026-09",
  281. )
  282. await database.attach_giveaway_message(giveaway["giveaway_id"], -100, 99)
  283. await database.giveawaysdb.update_one(
  284. {"giveaway_id": giveaway["giveaway_id"]},
  285. {"$set": {"ends_at": datetime.now(UTC) - timedelta(seconds=1)}},
  286. )
  287. due = await database.list_due_giveaways()
  288. assert [item["giveaway_id"] for item in due] == [giveaway["giveaway_id"]]
  289. await service.resume_pending_giveaway(due[0])
  290. finished = await database.get_giveaway(giveaway["giveaway_id"])
  291. assert finished["status"] == database.STATUS_FINISHED
  292. assert finished["result_pinned"] is True
  293. assert finished["publication_pending"] is False
  294. assert app_modules.app.pinned_messages == [(-100, finished["result_message_id"])]
  295. assert app_modules.app.deleted_messages == [(-100, 99)]
  296. assert await database.list_due_giveaways() == []
  297. async def test_external_snapshot_parser_and_observation_default(app_modules):
  298. risk = app_modules.load("wbb.services.external_risk_rules")
  299. assert risk.parse_entries(
  300. "yt_spam_domains", b"# comment\nexample.com\nhttps://bad.example/path\nnot a domain\n"
  301. ) == ["bad.example", "example.com"]
  302. await risk.ensure_indexes()
  303. await risk.snapshotsdb.insert_one({
  304. "snapshot_id": "s1", "bot_id": "primary", "source_id": "yt_spam_domains",
  305. "kind": "domain", "status": "pending",
  306. })
  307. await risk.entriesdb.insert_one({"snapshot_id": "s1", "value": "example.com"})
  308. await risk.publish_snapshot("s1")
  309. deleted: list[bool] = []
  310. async def delete():
  311. deleted.append(True)
  312. message = SimpleNamespace(
  313. id=5, chat=SimpleNamespace(id=-100),
  314. text="Visit https://sub.example.com/path", caption=None,
  315. entities=None, caption_entities=None,
  316. from_user=SimpleNamespace(id=10, is_bot=False), delete=delete,
  317. )
  318. await risk.enforce_external(message)
  319. assert deleted == []
  320. assert await risk.eventsdb.count_documents({"chat_id": -100}) == 1
  321. await risk.set_policy(-100, "delete")
  322. app_modules.app.members[(-100, 999)] = SimpleNamespace(
  323. status=ChatMemberStatus.ADMINISTRATOR,
  324. privileges=SimpleNamespace(can_delete_messages=True, can_restrict_members=False),
  325. )
  326. await risk.enforce_external(message)
  327. assert deleted == [True]
  328. async def test_admin_template_api_validates_target_and_exposes_risk_policy(app_modules):
  329. admin_api = app_modules.load("wbb.admin.api")
  330. application = admin_api.build_admin_application()
  331. await application["admin_api"].initialize()
  332. client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
  333. await client.start_server()
  334. try:
  335. login = await client.post(
  336. "/api/admin/v1/auth/login",
  337. json={"username": "admin", "password": "qwe0.123456"},
  338. )
  339. token = (await login.json())["data"]["csrf_token"]
  340. changed = await client.put(
  341. "/api/admin/v1/auth/password",
  342. headers={"X-CSRF-Token": token},
  343. json={"current_password": "qwe0.123456", "new_password": "changed-pass-123"},
  344. )
  345. token = (await changed.json())["data"]["csrf_token"]
  346. headers = {"X-CSRF-Token": token}
  347. app_modules.app.members[(-100, 999)] = _member(ChatMemberStatus.OWNER)
  348. app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.MEMBER)
  349. app_modules.app.chats[-200] = SimpleNamespace(
  350. id=-200, title="News", username="news", type="channel"
  351. )
  352. body = {
  353. "chat_id": -100, "title": "Monthly", "description": "",
  354. "prizes": [{"name": "First", "count": 1}],
  355. "day_of_month": 31, "start_time": "00:00",
  356. "draw_day_offset": 0, "draw_time": "20:00",
  357. "eligibility_targets": [{"chat_id": -200}], "confirm": True,
  358. }
  359. denied = await client.post(
  360. "/api/admin/v1/giveaways/templates", headers=headers, json=body
  361. )
  362. assert denied.status == 400
  363. app_modules.app.members[(-200, 999)] = _member(ChatMemberStatus.ADMINISTRATOR)
  364. created = await client.post(
  365. "/api/admin/v1/giveaways/templates", headers=headers, json=body
  366. )
  367. assert created.status == 201
  368. template = (await created.json())["data"]
  369. assert template["eligibility_targets"][0]["title"] == "News"
  370. listed = await client.get("/api/admin/v1/giveaways/templates")
  371. assert (await listed.json())["data"]["items"][0]["template_id"] == template["template_id"]
  372. policy = await client.get("/api/admin/v1/external-risk/chats/-100")
  373. assert (await policy.json())["data"]["mode"] == "observe"
  374. finally:
  375. await client.close()
  376. def test_new_admin_routes_keep_bot_role_permissions(app_modules):
  377. permissions = app_modules.load("wbb.services.bot_permissions")
  378. assert permissions.api_permission("POST", "/api/admin/v1/giveaways/templates") == "giveaways.manage"
  379. assert permissions.api_permission("POST", "/api/admin/v1/external-risk/sources/yt_spam_domains/stage") == "automation.manage"
  380. assert permissions.api_permission("PATCH", "/api/admin/v1/chats/-100/interaction-settings") == "automation.manage"