test_points.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. from __future__ import annotations
  2. from datetime import UTC, datetime, timedelta
  3. import pytest
  4. def test_leaderboard_labels_never_mention_members(app_modules):
  5. identity = app_modules.load("wbb.services.member_identity")
  6. assert (
  7. identity.non_mention_account_name(
  8. {
  9. "user_id": 10,
  10. "username": "alice",
  11. "first_name": "小明",
  12. "display_name": "小明 同学",
  13. }
  14. )
  15. == "小明 同学"
  16. )
  17. fallback = identity.non_mention_account_name(
  18. {
  19. "user_id": 11,
  20. "username": "alice",
  21. "first_name": "",
  22. "display_name": "",
  23. }
  24. )
  25. assert fallback == "用户 11"
  26. assert "@" not in fallback
  27. assert "tg://user" not in fallback
  28. async def test_adjustment_is_idempotent_and_balance_cannot_be_negative(app_modules):
  29. points = app_modules.load("wbb.utils.dbpoints")
  30. account, created = await points.adjust_points(
  31. chat_id=-100,
  32. user_id=10,
  33. delta=20,
  34. source=points.SOURCE_ADMIN,
  35. idempotency_key="adjust:1",
  36. actor_id="admin",
  37. reason="seed",
  38. )
  39. assert created is True
  40. assert account["balance"] == 20
  41. account, created = await points.adjust_points(
  42. chat_id=-100,
  43. user_id=10,
  44. delta=20,
  45. source=points.SOURCE_ADMIN,
  46. idempotency_key="adjust:1",
  47. actor_id="admin",
  48. reason="duplicate",
  49. )
  50. assert created is False
  51. assert account["balance"] == 20
  52. with pytest.raises(points.InsufficientPoints):
  53. await points.adjust_points(
  54. chat_id=-100,
  55. user_id=10,
  56. delta=-21,
  57. source=points.SOURCE_ADMIN,
  58. idempotency_key="adjust:2",
  59. reason="too much",
  60. )
  61. async def test_noop_set_is_recorded_and_retry_does_not_overwrite_new_points(app_modules):
  62. points = app_modules.load("wbb.utils.dbpoints")
  63. await points.adjust_points(
  64. chat_id=-100,
  65. user_id=10,
  66. delta=20,
  67. source=points.SOURCE_ADMIN,
  68. idempotency_key="set-seed",
  69. reason="seed",
  70. )
  71. account, created = await points.set_points(
  72. chat_id=-100,
  73. user_id=10,
  74. balance=20,
  75. actor_id="admin",
  76. reason="confirm balance",
  77. idempotency_key="set-request",
  78. )
  79. assert created is True
  80. assert account["balance"] == 20
  81. await points.adjust_points(
  82. chat_id=-100,
  83. user_id=10,
  84. delta=5,
  85. source=points.SOURCE_ADMIN,
  86. idempotency_key="set-later-award",
  87. reason="later award",
  88. )
  89. account, created = await points.set_points(
  90. chat_id=-100,
  91. user_id=10,
  92. balance=20,
  93. actor_id="admin",
  94. reason="retried request",
  95. idempotency_key="set-request",
  96. )
  97. assert created is False
  98. assert account["balance"] == 25
  99. transaction = await points.get_point_transaction_by_key("set-request")
  100. assert transaction["delta"] == 0
  101. async def test_checkin_uses_chat_timezone_day_boundary(app_modules):
  102. points = app_modules.load("wbb.utils.dbpoints")
  103. await points.set_point_rules(-100, {"enabled": True, "timezone": "Asia/Shanghai"})
  104. before_midnight = datetime(2026, 1, 1, 15, 59, tzinfo=UTC)
  105. account, created = await points.award_checkin(
  106. chat_id=-100,
  107. user_id=10,
  108. username="alice",
  109. first_name="Alice",
  110. now=before_midnight,
  111. )
  112. assert created is True
  113. assert account["balance"] == 10
  114. _, created = await points.award_checkin(
  115. chat_id=-100,
  116. user_id=10,
  117. username="alice",
  118. first_name="Alice",
  119. now=before_midnight + timedelta(seconds=30),
  120. )
  121. assert created is False
  122. account, created = await points.award_checkin(
  123. chat_id=-100,
  124. user_id=10,
  125. username="alice",
  126. first_name="Alice",
  127. now=before_midnight + timedelta(minutes=2),
  128. )
  129. assert created is True
  130. assert account["balance"] == 20
  131. async def test_checkin_messages_are_normalized_and_persisted(app_modules):
  132. points = app_modules.load("wbb.utils.dbpoints")
  133. saved = await points.set_point_rules(
  134. -100,
  135. {
  136. "checkin_success_message": " 获得 {points},余额 {balance} ",
  137. "checkin_already_message": "",
  138. },
  139. )
  140. assert saved["checkin_success_message"] == "获得 {points},余额 {balance}"
  141. assert (
  142. saved["checkin_already_message"]
  143. == points.DEFAULT_CHECKIN_ALREADY_MESSAGE
  144. )
  145. loaded = await points.get_point_rules(-100)
  146. assert loaded["checkin_success_message"] == "获得 {points},余额 {balance}"
  147. async def test_checkin_metrics_include_daily_rank_streak_and_month_total(app_modules):
  148. points = app_modules.load("wbb.utils.dbpoints")
  149. await points.set_point_rules(-100, {"enabled": True, "timezone": "Asia/Shanghai"})
  150. first_day = datetime(2026, 2, 1, 0, 0, tzinfo=UTC)
  151. await points.award_checkin(
  152. chat_id=-100,
  153. user_id=11,
  154. username="first",
  155. first_name="First",
  156. now=first_day,
  157. )
  158. await points.award_checkin(
  159. chat_id=-100,
  160. user_id=10,
  161. username="alice",
  162. first_name="Alice",
  163. now=first_day + timedelta(minutes=1),
  164. )
  165. first_metrics = await points.get_checkin_metrics(
  166. chat_id=-100,
  167. user_id=10,
  168. now=first_day + timedelta(minutes=1),
  169. )
  170. assert first_metrics == {"rank": 2, "streak": 1, "month_days": 1}
  171. second_day = first_day + timedelta(days=1)
  172. await points.award_checkin(
  173. chat_id=-100,
  174. user_id=10,
  175. username="alice",
  176. first_name="Alice",
  177. now=second_day,
  178. )
  179. second_metrics = await points.get_checkin_metrics(
  180. chat_id=-100,
  181. user_id=10,
  182. now=second_day,
  183. )
  184. assert second_metrics == {"rank": 1, "streak": 2, "month_days": 2}
  185. _, duplicate = await points.award_checkin(
  186. chat_id=-100,
  187. user_id=10,
  188. username="alice",
  189. first_name="Alice",
  190. now=second_day + timedelta(hours=1),
  191. )
  192. assert duplicate is False
  193. assert await points.get_checkin_metrics(
  194. chat_id=-100,
  195. user_id=10,
  196. now=second_day + timedelta(hours=1),
  197. ) == {"rank": 1, "streak": 2, "month_days": 2}
  198. fourth_day = first_day + timedelta(days=3)
  199. await points.award_checkin(
  200. chat_id=-100,
  201. user_id=10,
  202. username="alice",
  203. first_name="Alice",
  204. now=fourth_day,
  205. )
  206. fourth_metrics = await points.get_checkin_metrics(
  207. chat_id=-100,
  208. user_id=10,
  209. now=fourth_day,
  210. )
  211. assert fourth_metrics == {"rank": 1, "streak": 1, "month_days": 3}
  212. async def test_checkin_streak_continues_across_month_boundary(app_modules):
  213. points = app_modules.load("wbb.utils.dbpoints")
  214. await points.set_point_rules(-200, {"enabled": True, "timezone": "Asia/Shanghai"})
  215. february_last_day = datetime(2026, 2, 28, 0, 0, tzinfo=UTC)
  216. march_first_day = february_last_day + timedelta(days=1)
  217. for current in (february_last_day, march_first_day):
  218. await points.award_checkin(
  219. chat_id=-200,
  220. user_id=20,
  221. username="bob",
  222. first_name="Bob",
  223. now=current,
  224. )
  225. assert await points.get_checkin_metrics(
  226. chat_id=-200,
  227. user_id=20,
  228. now=march_first_day,
  229. ) == {"rank": 1, "streak": 2, "month_days": 1}
  230. async def test_activity_cooldown_duplicate_content_and_daily_cap(app_modules):
  231. points = app_modules.load("wbb.utils.dbpoints")
  232. await points.set_point_rules(
  233. -100,
  234. {
  235. "enabled": True,
  236. "activity_points": 1,
  237. "activity_cooldown_seconds": 300,
  238. "activity_daily_cap": 2,
  239. },
  240. )
  241. now = datetime(2099, 1, 1, 4, 0, tzinfo=UTC)
  242. _, first = await points.award_activity(
  243. chat_id=-100,
  244. user_id=10,
  245. message_id=1,
  246. content="hello",
  247. username=None,
  248. first_name="Alice",
  249. now=now,
  250. )
  251. _, cooldown = await points.award_activity(
  252. chat_id=-100,
  253. user_id=10,
  254. message_id=2,
  255. content="another",
  256. username=None,
  257. first_name="Alice",
  258. now=now + timedelta(seconds=299),
  259. )
  260. _, repeated = await points.award_activity(
  261. chat_id=-100,
  262. user_id=10,
  263. message_id=3,
  264. content="hello",
  265. username=None,
  266. first_name="Alice",
  267. now=now + timedelta(seconds=301),
  268. )
  269. _, second = await points.award_activity(
  270. chat_id=-100,
  271. user_id=10,
  272. message_id=4,
  273. content="different",
  274. username=None,
  275. first_name="Alice",
  276. now=now + timedelta(seconds=301),
  277. )
  278. _, capped = await points.award_activity(
  279. chat_id=-100,
  280. user_id=10,
  281. message_id=5,
  282. content="third",
  283. username=None,
  284. first_name="Alice",
  285. now=now + timedelta(seconds=602),
  286. )
  287. assert (first, cooldown, repeated, second, capped) == (True, False, False, True, False)
  288. assert (await points.get_point_account(-100, 10))["balance"] == 2
  289. async def test_upvote_pair_cooldown_and_reconciliation(app_modules):
  290. points = app_modules.load("wbb.utils.dbpoints")
  291. await points.set_point_rules(-100, {"enabled": True})
  292. now = datetime.now(UTC)
  293. _, first = await points.award_upvote(
  294. chat_id=-100,
  295. voter_id=20,
  296. target_id=10,
  297. message_id=1,
  298. username=None,
  299. first_name="Alice",
  300. now=now,
  301. )
  302. _, blocked = await points.award_upvote(
  303. chat_id=-100,
  304. voter_id=20,
  305. target_id=10,
  306. message_id=2,
  307. username=None,
  308. first_name="Alice",
  309. now=now + timedelta(hours=23),
  310. )
  311. _, next_day = await points.award_upvote(
  312. chat_id=-100,
  313. voter_id=20,
  314. target_id=10,
  315. message_id=3,
  316. username=None,
  317. first_name="Alice",
  318. now=now + timedelta(hours=25),
  319. )
  320. assert (first, blocked, next_day) == (True, False, True)
  321. await points.accountsdb.update_one(
  322. {"chat_id": -100, "user_id": 10}, {"$set": {"balance": 999}}
  323. )
  324. repaired = await points.reconcile_account(-100, 10)
  325. assert repaired["balance"] == 4