test_giveaway_points.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. from __future__ import annotations
  2. import asyncio
  3. from datetime import UTC, datetime, timedelta
  4. import pytest
  5. async def _seed_points(points, *, chat_id: int, user_id: int, amount: int) -> None:
  6. await points.adjust_points(
  7. chat_id=chat_id,
  8. user_id=user_id,
  9. delta=amount,
  10. source=points.SOURCE_ADMIN,
  11. idempotency_key=f"seed:{chat_id}:{user_id}",
  12. reason="test seed",
  13. )
  14. async def test_entry_cost_duplicate_click_and_cancel_refund(app_modules):
  15. points = app_modules.load("wbb.utils.dbpoints")
  16. giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
  17. service = app_modules.load("wbb.services.giveaways")
  18. await _seed_points(points, chat_id=-100, user_id=10, amount=100)
  19. giveaway = await giveaway_db.create_giveaway(
  20. chat_id=-100,
  21. creator_id=1,
  22. creator_name="Admin",
  23. title="Launch",
  24. description="",
  25. prizes=[{"name": "First", "count": 1, "points_reward": 5}],
  26. ends_at=datetime.now(UTC) + timedelta(hours=1),
  27. minimum_points=50,
  28. entry_cost=10,
  29. participation_reward=2,
  30. )
  31. first, _ = await service.join_giveaway(
  32. giveaway["giveaway_id"],
  33. user_id=10,
  34. username="alice",
  35. first_name="Alice",
  36. display_name="Alice Example",
  37. )
  38. duplicate, _ = await service.join_giveaway(
  39. giveaway["giveaway_id"],
  40. user_id=10,
  41. username="alice",
  42. first_name="Alice",
  43. display_name="Alice Example",
  44. )
  45. assert (first, duplicate) == ("ok", "duplicate")
  46. account = await points.get_point_account(-100, 10)
  47. assert account["balance"] == 90
  48. assert account["display_name"] == "Alice Example"
  49. participant = await giveaway_db.get_participant(giveaway["giveaway_id"], 10)
  50. assert participant["display_name"] == "Alice Example"
  51. assert "Alice Example" in service._participant_mention(participant)
  52. assert "@alice" not in service._participant_mention(participant)
  53. ok, _, _ = await service.cancel_and_refund_giveaway(
  54. giveaway["giveaway_id"], chat_id=-100, publish=False
  55. )
  56. again, _, _ = await service.cancel_and_refund_giveaway(
  57. giveaway["giveaway_id"], chat_id=-100, publish=False
  58. )
  59. assert ok is True
  60. assert again is False
  61. assert (await points.get_point_account(-100, 10))["balance"] == 100
  62. async def test_registration_window_blocks_early_entry_and_refreshes_count(app_modules):
  63. giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
  64. service = app_modules.load("wbb.services.giveaways")
  65. now = datetime.now(UTC)
  66. future = await giveaway_db.create_giveaway(
  67. chat_id=-100,
  68. creator_id=1,
  69. creator_name="Admin",
  70. title="Scheduled",
  71. description="",
  72. prizes=[{"name": "Winner", "count": 1}],
  73. starts_at=now + timedelta(hours=1),
  74. ends_at=now + timedelta(hours=2),
  75. )
  76. status, _ = await service.join_giveaway(
  77. future["giveaway_id"],
  78. user_id=10,
  79. username=None,
  80. first_name="Alice",
  81. )
  82. assert status == "not_started"
  83. assert await giveaway_db.count_participants(future["giveaway_id"]) == 0
  84. assert await giveaway_db.list_unannounced_started_giveaways() == []
  85. await giveaway_db.giveawaysdb.update_one(
  86. {"giveaway_id": future["giveaway_id"]},
  87. {"$set": {"starts_at": now - timedelta(minutes=1)}},
  88. )
  89. started = await giveaway_db.list_unannounced_started_giveaways()
  90. assert [item["giveaway_id"] for item in started] == [future["giveaway_id"]]
  91. await giveaway_db.mark_giveaway_start_announced(future["giveaway_id"])
  92. assert await giveaway_db.list_unannounced_started_giveaways() == []
  93. current = await giveaway_db.create_giveaway(
  94. chat_id=-100,
  95. creator_id=1,
  96. creator_name="Admin",
  97. title="Open",
  98. description="",
  99. prizes=[{"name": "Winner", "count": 1}],
  100. starts_at=now - timedelta(minutes=1),
  101. ends_at=now + timedelta(hours=1),
  102. )
  103. await giveaway_db.attach_giveaway_message(current["giveaway_id"], -100, 99)
  104. status, _ = await service.join_giveaway(
  105. current["giveaway_id"],
  106. user_id=10,
  107. username="alice",
  108. first_name="Alice",
  109. )
  110. assert status == "ok"
  111. assert len(app_modules.app.edited_messages) == 1
  112. assert app_modules.app.edited_messages[0][:2] == (-100, 99)
  113. assert "<b>参与人数:</b>1" in app_modules.app.edited_messages[0][2]
  114. async def test_registration_start_must_precede_draw_time(app_modules):
  115. giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
  116. now = datetime.now(UTC)
  117. with pytest.raises(ValueError, match="报名开始时间必须早于开奖时间"):
  118. await giveaway_db.create_giveaway(
  119. chat_id=-100,
  120. creator_id=1,
  121. creator_name="Admin",
  122. title="Invalid schedule",
  123. description="",
  124. prizes=[{"name": "Winner", "count": 1}],
  125. starts_at=now + timedelta(hours=2),
  126. ends_at=now + timedelta(hours=1),
  127. )
  128. async def test_running_giveaway_update_refreshes_message_and_rejects_stale_write(
  129. app_modules,
  130. ):
  131. giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
  132. service = app_modules.load("wbb.services.giveaways")
  133. now = datetime.now(UTC)
  134. giveaway = await giveaway_db.create_giveaway(
  135. chat_id=-100,
  136. creator_id=1,
  137. creator_name="Admin",
  138. title="Original title",
  139. description="Original description",
  140. prizes=[{"name": "Winner", "count": 1, "points_reward": 5}],
  141. starts_at=now + timedelta(hours=1),
  142. ends_at=now + timedelta(hours=2),
  143. minimum_points=20,
  144. entry_cost=5,
  145. participation_reward=2,
  146. )
  147. await giveaway_db.attach_giveaway_message(giveaway["giveaway_id"], -100, 99)
  148. current = await giveaway_db.get_giveaway(giveaway["giveaway_id"])
  149. new_starts_at = now + timedelta(hours=2)
  150. new_ends_at = now + timedelta(hours=3)
  151. updated = await service.update_and_refresh_giveaway(
  152. giveaway["giveaway_id"],
  153. title="Updated title",
  154. description="Valid for one month",
  155. starts_at=new_starts_at,
  156. ends_at=new_ends_at,
  157. expected_updated_at=current["updated_at"],
  158. )
  159. assert updated["title"] == "Updated title"
  160. assert updated["description"] == "Valid for one month"
  161. assert int(giveaway_db.as_utc(updated["starts_at"]).timestamp() * 1000) == int(
  162. new_starts_at.timestamp() * 1000
  163. )
  164. assert int(giveaway_db.as_utc(updated["ends_at"]).timestamp() * 1000) == int(
  165. new_ends_at.timestamp() * 1000
  166. )
  167. assert updated["prizes"] == giveaway["prizes"]
  168. assert updated["minimum_points"] == 20
  169. assert updated["entry_cost"] == 5
  170. assert updated["participation_reward"] == 2
  171. assert app_modules.app.edited_messages[-1][:2] == (-100, 99)
  172. assert "Updated title" in app_modules.app.edited_messages[-1][2]
  173. assert "Valid for one month" in app_modules.app.edited_messages[-1][2]
  174. with pytest.raises(service.GiveawayServiceError) as error:
  175. await service.update_and_refresh_giveaway(
  176. giveaway["giveaway_id"],
  177. title="Stale title",
  178. description="Stale description",
  179. starts_at=new_starts_at,
  180. ends_at=new_ends_at,
  181. expected_updated_at=current["updated_at"],
  182. )
  183. assert error.value.code == "giveaway_stale"
  184. async def test_running_giveaway_cannot_move_registration_start_after_it_begins(
  185. app_modules,
  186. ):
  187. giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
  188. service = app_modules.load("wbb.services.giveaways")
  189. now = datetime.now(UTC)
  190. giveaway = await giveaway_db.create_giveaway(
  191. chat_id=-100,
  192. creator_id=1,
  193. creator_name="Admin",
  194. title="Open giveaway",
  195. description="",
  196. prizes=[{"name": "Winner", "count": 1}],
  197. starts_at=now - timedelta(minutes=1),
  198. ends_at=now + timedelta(hours=1),
  199. )
  200. with pytest.raises(service.GiveawayServiceError) as error:
  201. await service.update_and_refresh_giveaway(
  202. giveaway["giveaway_id"],
  203. title="Open giveaway",
  204. description="Updated",
  205. starts_at=now + timedelta(minutes=1),
  206. ends_at=now + timedelta(hours=2),
  207. expected_updated_at=giveaway["updated_at"],
  208. )
  209. assert error.value.code == "giveaway_registration_started"
  210. async def test_draw_rewards_are_idempotent_under_competing_finish_calls(app_modules):
  211. points = app_modules.load("wbb.utils.dbpoints")
  212. giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
  213. service = app_modules.load("wbb.services.giveaways")
  214. for user_id in (10, 11):
  215. await _seed_points(points, chat_id=-100, user_id=user_id, amount=20)
  216. giveaway = await giveaway_db.create_giveaway(
  217. chat_id=-100,
  218. creator_id=1,
  219. creator_name="Admin",
  220. title="Draw",
  221. description="",
  222. prizes=[{"name": "Winner", "count": 1, "points_reward": 5}],
  223. ends_at=datetime.now(UTC) + timedelta(hours=1),
  224. entry_cost=3,
  225. participation_reward=2,
  226. )
  227. for user_id in (10, 11):
  228. status, _ = await service.join_giveaway(
  229. giveaway["giveaway_id"],
  230. user_id=user_id,
  231. username=None,
  232. first_name=str(user_id),
  233. )
  234. assert status == "ok"
  235. results = await asyncio.gather(
  236. service.finish_and_publish_giveaway(giveaway["giveaway_id"], publish=False),
  237. service.finish_and_publish_giveaway(giveaway["giveaway_id"], publish=False),
  238. )
  239. assert sum(1 for result in results if result[0]) == 1
  240. finished = await giveaway_db.get_giveaway(giveaway["giveaway_id"])
  241. assert finished["status"] == giveaway_db.STATUS_FINISHED
  242. winner_id = int(finished["winners"][0]["user_id"])
  243. balances = {
  244. user_id: (await points.get_point_account(-100, user_id))["balance"]
  245. for user_id in (10, 11)
  246. }
  247. assert balances[winner_id] == 24
  248. assert balances[10 if winner_id == 11 else 11] == 19
  249. async def test_draw_pins_result_and_deletes_original_giveaway(app_modules):
  250. giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
  251. service = app_modules.load("wbb.services.giveaways")
  252. giveaway = await giveaway_db.create_giveaway(
  253. chat_id=-100,
  254. creator_id=1,
  255. creator_name="Admin",
  256. title="Pinned draw",
  257. description="",
  258. prizes=[{"name": "Winner", "count": 1}],
  259. ends_at=datetime.now(UTC) + timedelta(hours=1),
  260. )
  261. await giveaway_db.attach_giveaway_message(
  262. giveaway["giveaway_id"], -100, 99
  263. )
  264. await giveaway_db.add_participant(
  265. giveaway_id=giveaway["giveaway_id"],
  266. user_id=10,
  267. username="alice",
  268. first_name="Alice",
  269. )
  270. ok, _, _ = await service.finish_and_publish_giveaway(
  271. giveaway["giveaway_id"]
  272. )
  273. assert ok is True
  274. assert app_modules.app.pinned_messages == [(-100, 1)]
  275. assert app_modules.app.deleted_messages == [(-100, 99)]
  276. assert app_modules.app.unpinned_messages == []
  277. assert "抽奖结果" in app_modules.app.sent_messages[0][1]
  278. async def test_participant_write_failure_creates_compensating_refund(
  279. app_modules, monkeypatch
  280. ):
  281. points = app_modules.load("wbb.utils.dbpoints")
  282. giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
  283. service = app_modules.load("wbb.services.giveaways")
  284. await _seed_points(points, chat_id=-100, user_id=10, amount=20)
  285. giveaway = await giveaway_db.create_giveaway(
  286. chat_id=-100,
  287. creator_id=1,
  288. creator_name="Admin",
  289. title="Failure",
  290. description="",
  291. prizes=[{"name": "Winner", "count": 1}],
  292. ends_at=datetime.now(UTC) + timedelta(hours=1),
  293. entry_cost=5,
  294. )
  295. original_add_participant = service.add_participant
  296. async def fail_insert(**_kwargs):
  297. raise RuntimeError("simulated participant write failure")
  298. monkeypatch.setattr(service, "add_participant", fail_insert)
  299. with pytest.raises(RuntimeError):
  300. await service.join_giveaway(
  301. giveaway["giveaway_id"],
  302. user_id=10,
  303. username=None,
  304. first_name="Alice",
  305. )
  306. assert (await points.get_point_account(-100, 10))["balance"] == 20
  307. transactions, _ = await points.list_point_transactions(
  308. chat_id=-100, user_id=10, page_size=20
  309. )
  310. assert {item["source"] for item in transactions} >= {
  311. points.SOURCE_GIVEAWAY_ENTRY,
  312. points.SOURCE_GIVEAWAY_REFUND,
  313. }
  314. monkeypatch.setattr(service, "add_participant", original_add_participant)
  315. status, _ = await service.join_giveaway(
  316. giveaway["giveaway_id"],
  317. user_id=10,
  318. username=None,
  319. first_name="Alice",
  320. )
  321. assert status == "ok"
  322. assert (await points.get_point_account(-100, 10))["balance"] == 15
  323. async def test_retry_reuses_uncompensated_entry_debit(app_modules):
  324. points = app_modules.load("wbb.utils.dbpoints")
  325. giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
  326. service = app_modules.load("wbb.services.giveaways")
  327. await _seed_points(points, chat_id=-100, user_id=10, amount=20)
  328. giveaway = await giveaway_db.create_giveaway(
  329. chat_id=-100,
  330. creator_id=1,
  331. creator_name="Admin",
  332. title="Crash recovery",
  333. description="",
  334. prizes=[{"name": "Winner", "count": 1}],
  335. ends_at=datetime.now(UTC) + timedelta(hours=1),
  336. minimum_points=20,
  337. entry_cost=5,
  338. )
  339. await points.adjust_points(
  340. chat_id=-100,
  341. user_id=10,
  342. delta=-5,
  343. source=points.SOURCE_GIVEAWAY_ENTRY,
  344. idempotency_key=f"giveaway-entry:{giveaway['giveaway_id']}:10",
  345. reference_id=giveaway["giveaway_id"],
  346. reason="simulated crash after debit",
  347. )
  348. status, _ = await service.join_giveaway(
  349. giveaway["giveaway_id"],
  350. user_id=10,
  351. username=None,
  352. first_name="Alice",
  353. )
  354. assert status == "ok"
  355. assert (await points.get_point_account(-100, 10))["balance"] == 15
  356. transactions, _ = await points.list_point_transactions(
  357. chat_id=-100, user_id=10, source=points.SOURCE_GIVEAWAY_ENTRY
  358. )
  359. assert len(transactions) == 1