@openora/core 0.4.1-canary.39 → 0.4.1-canary.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/contract/index.d.ts +2 -0
  3. package/dist/admin-console/contract/index.d.ts.map +1 -1
  4. package/dist/admin-console/router/index.d.ts +1 -0
  5. package/dist/admin-console/router/index.d.ts.map +1 -1
  6. package/dist/admin-console/service/backoffice.service.d.ts +1 -1
  7. package/dist/audit/plugin.d.ts.map +1 -1
  8. package/dist/audit/plugin.js +48 -1
  9. package/dist/audit/plugin.js.map +1 -1
  10. package/dist/common/errors/errors.classes.js +3 -3
  11. package/dist/common/errors/errors.classes.js.map +1 -1
  12. package/dist/common/errors/errors.mapper.js +1 -1
  13. package/dist/common/errors/errors.mapper.js.map +1 -1
  14. package/dist/compliance/__tests__/hmac-kyc-webhook-verifier.test.js +66 -12
  15. package/dist/compliance/__tests__/hmac-kyc-webhook-verifier.test.js.map +1 -1
  16. package/dist/compliance/__tests__/kyc-admin.router.test.d.ts +2 -0
  17. package/dist/compliance/__tests__/kyc-admin.router.test.d.ts.map +1 -0
  18. package/dist/compliance/__tests__/kyc-admin.router.test.js +191 -0
  19. package/dist/compliance/__tests__/kyc-admin.router.test.js.map +1 -0
  20. package/dist/compliance/__tests__/kyc-webhook.router.test.d.ts +2 -0
  21. package/dist/compliance/__tests__/kyc-webhook.router.test.d.ts.map +1 -0
  22. package/dist/compliance/__tests__/kyc-webhook.router.test.js +148 -0
  23. package/dist/compliance/__tests__/kyc-webhook.router.test.js.map +1 -0
  24. package/dist/compliance/__tests__/kyc.service.test.js +16 -10
  25. package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
  26. package/dist/compliance/__tests__/rg.router.test.js +5 -1
  27. package/dist/compliance/__tests__/rg.router.test.js.map +1 -1
  28. package/dist/compliance/adapters/hmac-kyc-webhook-verifier.d.ts +16 -3
  29. package/dist/compliance/adapters/hmac-kyc-webhook-verifier.d.ts.map +1 -1
  30. package/dist/compliance/adapters/hmac-kyc-webhook-verifier.js +47 -5
  31. package/dist/compliance/adapters/hmac-kyc-webhook-verifier.js.map +1 -1
  32. package/dist/compliance/contract/index.d.ts +312 -0
  33. package/dist/compliance/contract/index.d.ts.map +1 -1
  34. package/dist/compliance/contract/index.js +50 -1
  35. package/dist/compliance/contract/index.js.map +1 -1
  36. package/dist/compliance/drizzle/migrations/0002_condemned_monster_badoon.sql +3 -0
  37. package/dist/compliance/drizzle/migrations/0003_public_spirit.sql +1 -0
  38. package/dist/compliance/drizzle/migrations/meta/0002_snapshot.json +673 -0
  39. package/dist/compliance/drizzle/migrations/meta/0003_snapshot.json +679 -0
  40. package/dist/compliance/drizzle/migrations/meta/_journal.json +14 -0
  41. package/dist/compliance/plugin.d.ts.map +1 -1
  42. package/dist/compliance/plugin.js +23 -3
  43. package/dist/compliance/plugin.js.map +1 -1
  44. package/dist/compliance/router/index.d.ts +186 -2
  45. package/dist/compliance/router/index.d.ts.map +1 -1
  46. package/dist/compliance/router/index.js +52 -3
  47. package/dist/compliance/router/index.js.map +1 -1
  48. package/dist/compliance/schema/index.d.ts +65 -3
  49. package/dist/compliance/schema/index.d.ts.map +1 -1
  50. package/dist/compliance/schema/index.js +8 -0
  51. package/dist/compliance/schema/index.js.map +1 -1
  52. package/dist/compliance/service/kyc.service.d.ts +153 -8
  53. package/dist/compliance/service/kyc.service.d.ts.map +1 -1
  54. package/dist/compliance/service/kyc.service.js +264 -32
  55. package/dist/compliance/service/kyc.service.js.map +1 -1
  56. package/dist/contracts/adapters/audit.d.ts +1 -1
  57. package/dist/contracts/adapters/audit.d.ts.map +1 -1
  58. package/dist/contracts/adapters/audit.js.map +1 -1
  59. package/dist/contracts/adapters/identity-reader.d.ts +9 -3
  60. package/dist/contracts/adapters/identity-reader.d.ts.map +1 -1
  61. package/dist/contracts/adapters/identity-reader.js.map +1 -1
  62. package/dist/contracts/adapters/index.d.ts +4 -2
  63. package/dist/contracts/adapters/index.d.ts.map +1 -1
  64. package/dist/contracts/adapters/index.js +2 -1
  65. package/dist/contracts/adapters/index.js.map +1 -1
  66. package/dist/contracts/adapters/kyc.d.ts +90 -5
  67. package/dist/contracts/adapters/kyc.d.ts.map +1 -1
  68. package/dist/contracts/adapters/kyc.js +23 -0
  69. package/dist/contracts/adapters/kyc.js.map +1 -1
  70. package/dist/contracts/adapters/tag-evaluation-commands.d.ts +10 -0
  71. package/dist/contracts/adapters/tag-evaluation-commands.d.ts.map +1 -0
  72. package/dist/contracts/adapters/tag-evaluation-commands.js +11 -0
  73. package/dist/contracts/adapters/tag-evaluation-commands.js.map +1 -0
  74. package/dist/contracts/adapters/wallet-reader.d.ts +8 -0
  75. package/dist/contracts/adapters/wallet-reader.d.ts.map +1 -1
  76. package/dist/contracts/adapters/wallet-reader.js.map +1 -1
  77. package/dist/contracts/schemas/__tests__/events.test.js +1 -1
  78. package/dist/contracts/schemas/__tests__/player-schemas.test.d.ts +2 -0
  79. package/dist/contracts/schemas/__tests__/player-schemas.test.d.ts.map +1 -0
  80. package/dist/contracts/schemas/__tests__/player-schemas.test.js +30 -0
  81. package/dist/contracts/schemas/__tests__/player-schemas.test.js.map +1 -0
  82. package/dist/contracts/schemas/events.d.ts +84 -0
  83. package/dist/contracts/schemas/events.d.ts.map +1 -1
  84. package/dist/contracts/schemas/events.js +24 -2
  85. package/dist/contracts/schemas/events.js.map +1 -1
  86. package/dist/contracts/schemas/platform-config.d.ts +13 -1
  87. package/dist/contracts/schemas/platform-config.d.ts.map +1 -1
  88. package/dist/contracts/schemas/player.d.ts +36 -3
  89. package/dist/contracts/schemas/player.d.ts.map +1 -1
  90. package/dist/contracts/schemas/player.js +14 -0
  91. package/dist/contracts/schemas/player.js.map +1 -1
  92. package/dist/contracts/schemas/tag.d.ts +85 -1
  93. package/dist/contracts/schemas/tag.d.ts.map +1 -1
  94. package/dist/contracts/schemas/tag.js +12 -0
  95. package/dist/contracts/schemas/tag.js.map +1 -1
  96. package/dist/engagement/notifications/contract/index.d.ts +5 -1
  97. package/dist/engagement/notifications/contract/index.d.ts.map +1 -1
  98. package/dist/engagement/notifications/contract/index.js +5 -1
  99. package/dist/engagement/notifications/contract/index.js.map +1 -1
  100. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  101. package/dist/engagement/notifications/plugin.js +43 -3
  102. package/dist/engagement/notifications/plugin.js.map +1 -1
  103. package/dist/engagement/notifications/router/index.d.ts +1 -0
  104. package/dist/engagement/notifications/router/index.d.ts.map +1 -1
  105. package/dist/iam/service/iam.service.d.ts +1 -1
  106. package/dist/pam/identity/__tests__/admin-user-directory.test.js +1 -1
  107. package/dist/pam/identity/adapters/identity-reader.service.d.ts +6 -4
  108. package/dist/pam/identity/adapters/identity-reader.service.d.ts.map +1 -1
  109. package/dist/pam/identity/adapters/identity-reader.service.js +19 -1
  110. package/dist/pam/identity/adapters/identity-reader.service.js.map +1 -1
  111. package/dist/pam/identity/admin-user-directory.d.ts +1 -1
  112. package/dist/pam/identity/admin-user-directory.d.ts.map +1 -1
  113. package/dist/pam/identity/admin-user-directory.js +6 -3
  114. package/dist/pam/identity/admin-user-directory.js.map +1 -1
  115. package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
  116. package/dist/pam/identity/service/identity.service.js +4 -0
  117. package/dist/pam/identity/service/identity.service.js.map +1 -1
  118. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js +5 -2
  119. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js.map +1 -1
  120. package/dist/pam/player-management/__tests__/player.router.test.js +27 -20
  121. package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
  122. package/dist/pam/player-management/__tests__/player.service.test.js +30 -31
  123. package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
  124. package/dist/pam/player-management/contract/index.d.ts +37 -9
  125. package/dist/pam/player-management/contract/index.d.ts.map +1 -1
  126. package/dist/pam/player-management/contract/index.js +0 -1
  127. package/dist/pam/player-management/contract/index.js.map +1 -1
  128. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  129. package/dist/pam/player-management/plugin.js +3 -2
  130. package/dist/pam/player-management/plugin.js.map +1 -1
  131. package/dist/pam/player-management/router/index.d.ts +32 -10
  132. package/dist/pam/player-management/router/index.d.ts.map +1 -1
  133. package/dist/pam/player-management/router/index.js +48 -16
  134. package/dist/pam/player-management/router/index.js.map +1 -1
  135. package/dist/pam/player-management/service/kyc-status-writer.d.ts +3 -2
  136. package/dist/pam/player-management/service/kyc-status-writer.d.ts.map +1 -1
  137. package/dist/pam/player-management/service/kyc-status-writer.js +24 -10
  138. package/dist/pam/player-management/service/kyc-status-writer.js.map +1 -1
  139. package/dist/pam/player-management/service/player.service.d.ts +10 -9
  140. package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
  141. package/dist/pam/player-management/service/player.service.js +24 -12
  142. package/dist/pam/player-management/service/player.service.js.map +1 -1
  143. package/dist/pam/profile/contract/index.d.ts +2 -0
  144. package/dist/pam/profile/contract/index.d.ts.map +1 -1
  145. package/dist/pam/profile/drizzle/migrations/0001_powerful_tiger_shark.sql +1 -0
  146. package/dist/pam/profile/drizzle/migrations/meta/0001_snapshot.json +191 -0
  147. package/dist/pam/profile/drizzle/migrations/meta/_journal.json +7 -0
  148. package/dist/pam/profile/react/profile.d.ts +2 -2
  149. package/dist/pam/profile/router/index.d.ts +2 -0
  150. package/dist/pam/profile/router/index.d.ts.map +1 -1
  151. package/dist/pam/profile/schema/index.d.ts +3 -3
  152. package/dist/pam/profile/schema/index.d.ts.map +1 -1
  153. package/dist/pam/profile/service/profile.service.d.ts +2 -2
  154. package/dist/pam/shared/player-mapper.d.ts +1 -1
  155. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +619 -19
  156. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
  157. package/dist/pam/tag/__tests__/tag-mappers.test.js +25 -0
  158. package/dist/pam/tag/__tests__/tag-mappers.test.js.map +1 -1
  159. package/dist/pam/tag/__tests__/tag.router.test.js +71 -1
  160. package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
  161. package/dist/pam/tag/__tests__/tag.service.test.js +342 -15
  162. package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
  163. package/dist/pam/tag/contract/index.d.ts +123 -0
  164. package/dist/pam/tag/contract/index.d.ts.map +1 -1
  165. package/dist/pam/tag/contract/index.js +3 -0
  166. package/dist/pam/tag/contract/index.js.map +1 -1
  167. package/dist/pam/tag/contract/player-tag-assign-metadata.d.ts +37 -0
  168. package/dist/pam/tag/contract/player-tag-assign-metadata.d.ts.map +1 -0
  169. package/dist/pam/tag/contract/player-tag-assign-metadata.js +20 -0
  170. package/dist/pam/tag/contract/player-tag-assign-metadata.js.map +1 -0
  171. package/dist/pam/tag/drizzle/migrations/0002_silent_skaar.sql +4 -0
  172. package/dist/pam/tag/drizzle/migrations/0003_overconfident_guardian.sql +3 -0
  173. package/dist/pam/tag/drizzle/migrations/0004_curvy_wither.sql +2 -0
  174. package/dist/pam/tag/drizzle/migrations/meta/0002_snapshot.json +335 -0
  175. package/dist/pam/tag/drizzle/migrations/meta/0003_snapshot.json +343 -0
  176. package/dist/pam/tag/drizzle/migrations/meta/0004_snapshot.json +345 -0
  177. package/dist/pam/tag/drizzle/migrations/meta/_journal.json +21 -0
  178. package/dist/pam/tag/migrate.d.ts.map +1 -1
  179. package/dist/pam/tag/migrate.js +34 -0
  180. package/dist/pam/tag/migrate.js.map +1 -1
  181. package/dist/pam/tag/plugin.d.ts.map +1 -1
  182. package/dist/pam/tag/plugin.js +43 -16
  183. package/dist/pam/tag/plugin.js.map +1 -1
  184. package/dist/pam/tag/router/index.d.ts +105 -0
  185. package/dist/pam/tag/router/index.d.ts.map +1 -1
  186. package/dist/pam/tag/router/index.js +34 -18
  187. package/dist/pam/tag/router/index.js.map +1 -1
  188. package/dist/pam/tag/schema/index.d.ts +42 -3
  189. package/dist/pam/tag/schema/index.d.ts.map +1 -1
  190. package/dist/pam/tag/schema/index.js +13 -2
  191. package/dist/pam/tag/schema/index.js.map +1 -1
  192. package/dist/pam/tag/seed/data/default-tag-rules.d.ts.map +1 -1
  193. package/dist/pam/tag/seed/data/default-tag-rules.js +42 -0
  194. package/dist/pam/tag/seed/data/default-tag-rules.js.map +1 -1
  195. package/dist/pam/tag/seed/data/default-tags.d.ts.map +1 -1
  196. package/dist/pam/tag/seed/data/default-tags.js +6 -0
  197. package/dist/pam/tag/seed/data/default-tags.js.map +1 -1
  198. package/dist/pam/tag/service/tag-evaluation.service.d.ts +141 -16
  199. package/dist/pam/tag/service/tag-evaluation.service.d.ts.map +1 -1
  200. package/dist/pam/tag/service/tag-evaluation.service.js +472 -47
  201. package/dist/pam/tag/service/tag-evaluation.service.js.map +1 -1
  202. package/dist/pam/tag/service/tag-mappers.d.ts +1 -1
  203. package/dist/pam/tag/service/tag-mappers.d.ts.map +1 -1
  204. package/dist/pam/tag/service/tag-mappers.js +1 -0
  205. package/dist/pam/tag/service/tag-mappers.js.map +1 -1
  206. package/dist/pam/tag/service/tag-rule.service.d.ts +3 -3
  207. package/dist/pam/tag/service/tag.service.d.ts +141 -11
  208. package/dist/pam/tag/service/tag.service.d.ts.map +1 -1
  209. package/dist/pam/tag/service/tag.service.js +242 -23
  210. package/dist/pam/tag/service/tag.service.js.map +1 -1
  211. package/dist/server/auth/permissions.d.ts +12 -0
  212. package/dist/server/auth/permissions.d.ts.map +1 -1
  213. package/dist/server/auth/permissions.js +2 -0
  214. package/dist/server/auth/permissions.js.map +1 -1
  215. package/dist/server/db/__tests__/migrate.test.js +79 -2
  216. package/dist/server/db/__tests__/migrate.test.js.map +1 -1
  217. package/dist/server/db/migrate.d.ts +11 -0
  218. package/dist/server/db/migrate.d.ts.map +1 -1
  219. package/dist/server/db/migrate.js +3 -0
  220. package/dist/server/db/migrate.js.map +1 -1
  221. package/dist/server/kernel/bullmq-job-queue.d.ts.map +1 -1
  222. package/dist/server/kernel/bullmq-job-queue.js +17 -0
  223. package/dist/server/kernel/bullmq-job-queue.js.map +1 -1
  224. package/dist/server/plugin-host/__tests__/core-plugins.test.js +1 -1
  225. package/dist/server/plugin-host/__tests__/core-plugins.test.js.map +1 -1
  226. package/dist/server/plugin-host/define-plugin.d.ts +2 -2
  227. package/dist/server/plugin-host/define-plugin.d.ts.map +1 -1
  228. package/dist/server/plugin-host/define-plugin.js.map +1 -1
  229. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +60 -0
  230. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
  231. package/dist/wallet/__tests__/wallet.service.test.js +59 -0
  232. package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
  233. package/dist/wallet/adapters/wallet-reader.service.d.ts +1 -0
  234. package/dist/wallet/adapters/wallet-reader.service.d.ts.map +1 -1
  235. package/dist/wallet/adapters/wallet-reader.service.js +18 -1
  236. package/dist/wallet/adapters/wallet-reader.service.js.map +1 -1
  237. package/dist/wallet/contract/index.d.ts +4 -0
  238. package/dist/wallet/contract/index.d.ts.map +1 -1
  239. package/dist/wallet/plugin.d.ts.map +1 -1
  240. package/dist/wallet/plugin.js +7 -4
  241. package/dist/wallet/plugin.js.map +1 -1
  242. package/dist/wallet/router/index.d.ts +2 -0
  243. package/dist/wallet/router/index.d.ts.map +1 -1
  244. package/dist/wallet/service/wallet.service.d.ts +5 -4
  245. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  246. package/dist/wallet/service/wallet.service.js +28 -8
  247. package/dist/wallet/service/wallet.service.js.map +1 -1
  248. package/package.json +1 -1
@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vites
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { and, eq, isNull, sql } from 'drizzle-orm';
4
4
  import { createTestDb } from '@openora/core/testing';
5
+ import { user } from '@openora/core/pam/schema/identity';
6
+ import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity';
7
+ import { player } from '@openora/core/pam/schema/profile';
8
+ import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile';
5
9
  import { mock, makeEventBus } from '../../../testing/mock.js';
6
10
  import { TagEvaluationService } from '../service/tag-evaluation.service.js';
7
11
  import { SYSTEM_ACTOR_ID } from '../service/tag-mappers.js';
@@ -28,10 +32,18 @@ async function seedRule(key, overrides = {}) {
28
32
  }
29
33
  async function seedKycPendingRule(overrides = {}) {
30
34
  const kycPendingTag = await seedRule('kyc_pending', overrides);
31
- const kycRejectedTag = await seedTagRow('kyc_rejected');
35
+ const kycRejectedTag = await seedRule('kyc_rejected');
32
36
  return { kycPendingTag, kycRejectedTag };
33
37
  }
34
- async function seedActiveAssignment(playerId, tagRow) {
38
+ // onAuthenticatedLogin unconditionally tries to remove BOTH the inactive and
39
+ // dormant_high_roller catalog tags on every login - both rows must exist regardless
40
+ // of which one (if either) a given test actually cares about.
41
+ async function seedLoginCleanupTags() {
42
+ const inactiveTag = await seedTagRow('inactive');
43
+ const dormantTag = await seedTagRow('dormant_high_roller');
44
+ return { inactiveTag, dormantTag };
45
+ }
46
+ async function seedActiveAssignment(playerId, tagRow, overrides = {}) {
35
47
  const [row] = await db.drizzle.db
36
48
  .insert(playerTag)
37
49
  .values({
@@ -40,6 +52,7 @@ async function seedActiveAssignment(playerId, tagRow) {
40
52
  assignReason: 'seed fixture',
41
53
  assignActor: 'manual',
42
54
  assignActorUserId: null,
55
+ ...overrides,
43
56
  })
44
57
  .returning();
45
58
  return row;
@@ -59,6 +72,40 @@ async function assignmentRow(playerId, tagRow) {
59
72
  .where(and(eq(playerTag.playerId, playerId), eq(playerTag.tagId, tagRow.id)));
60
73
  return row;
61
74
  }
75
+ async function activeAssignmentRow(playerId, tagRow) {
76
+ const [row] = await db.drizzle.db
77
+ .select()
78
+ .from(playerTag)
79
+ .where(and(eq(playerTag.playerId, playerId), eq(playerTag.tagId, tagRow.id), isNull(playerTag.removedAt)));
80
+ return row;
81
+ }
82
+ async function seedUser(overrides = {}) {
83
+ const [row] = await db.drizzle.db
84
+ .insert(user)
85
+ .values({ name: 'Player', email: `${randomUUID()}@example.com`, ...overrides })
86
+ .returning();
87
+ return row;
88
+ }
89
+ async function seedPlayer(userId, overrides = {}) {
90
+ const [row] = await db.drizzle.db
91
+ .insert(player)
92
+ .values({ userId, displayName: 'Player', ...overrides })
93
+ .returning();
94
+ return row;
95
+ }
96
+ async function seedPlayerWithUser() {
97
+ const account = await seedUser();
98
+ const row = await seedPlayer(account.id);
99
+ return { account, player: row };
100
+ }
101
+ // Resolves identityReader.getPlayerIdByUserId for a fixed set of (userId, playerId)
102
+ // pairs - the real TagService methods that join through the `player` table
103
+ // (getActiveTagKeys, listActiveHoldersByTagKey) need actual player rows, so those
104
+ // tests seed a real player and must resolve to its real id, not just echo the userId.
105
+ function identityLookup(pairs) {
106
+ const map = new Map(pairs);
107
+ return vi.fn(async (uid) => map.get(uid) ?? null);
108
+ }
62
109
  function makeServices(overrides = {}) {
63
110
  const events = makeEventBus();
64
111
  const tagService = new TagService(db.drizzle, events);
@@ -66,16 +113,29 @@ function makeServices(overrides = {}) {
66
113
  const walletReader = mock({
67
114
  getLifetimeDeposit: vi.fn().mockResolvedValue('0'),
68
115
  getWithdrawalCountInWindow: vi.fn().mockResolvedValue(0),
116
+ getWithdrawalCountsInWindow: vi.fn().mockResolvedValue(new Map()),
69
117
  ...overrides.walletReader,
70
118
  });
71
119
  const identityReader = mock({
72
120
  getLastLoginAt: vi.fn().mockResolvedValue(null),
73
121
  getPlayerIdsInactiveSince: vi.fn().mockResolvedValue([]),
74
122
  getPlayerIdByUserId: vi.fn(async (uid) => uid),
123
+ getPlayerKycStatusByUserId: vi.fn().mockResolvedValue('pending'),
124
+ getPlayerUserIdsSharingLoginIp: vi.fn().mockResolvedValue([]),
75
125
  ...overrides.identityReader,
76
126
  });
77
- const service = new TagEvaluationService(tagService, ruleService, walletReader, identityReader);
78
- return { service, walletReader, identityReader };
127
+ const adminUserDirectory = mock({
128
+ lookupPlayers: vi.fn().mockResolvedValue([]),
129
+ ...overrides.adminUserDirectory,
130
+ });
131
+ const service = new TagEvaluationService({
132
+ tag: tagService,
133
+ rule: ruleService,
134
+ walletReader,
135
+ identityReader,
136
+ adminUserDirectory,
137
+ });
138
+ return { service, tagService, ruleService, walletReader, identityReader, adminUserDirectory };
79
139
  }
80
140
  function depositPayload(userId, amount) {
81
141
  return { userId, amount: String(amount), currency: 'USD', transactionId: randomUUID() };
@@ -84,16 +144,23 @@ function withdrawalPayload(userId, amount) {
84
144
  return { userId, amount: String(amount), currency: 'USD', transactionId: randomUUID() };
85
145
  }
86
146
  function kycUpdatedPayload(userId, status) {
87
- return { userId, actorId: SYSTEM_ACTOR_ID, status, previousStatus: 'pending' };
147
+ return {
148
+ userId,
149
+ actorId: SYSTEM_ACTOR_ID,
150
+ status,
151
+ previousStatus: 'pending',
152
+ reason: null,
153
+ source: 'webhook',
154
+ };
88
155
  }
89
156
  beforeAll(async () => {
90
- db = await createTestDb([migrate]);
157
+ db = await createTestDb([migrateIdentity, migrateProfile, migrate]);
91
158
  });
92
159
  afterAll(async () => {
93
160
  await db.drop();
94
161
  });
95
162
  beforeEach(async () => {
96
- await db.drizzle.db.execute(sql `TRUNCATE ${playerTag}, ${tagRule}, ${tag} RESTART IDENTITY CASCADE`);
163
+ await db.drizzle.db.execute(sql `TRUNCATE ${playerTag}, ${tagRule}, ${tag}, ${player}, ${user} RESTART IDENTITY CASCADE`);
97
164
  });
98
165
  describe('TagEvaluationService.onDepositCompleted (real PG)', () => {
99
166
  it('assigns large_depositor when a single deposit meets the amount threshold', async () => {
@@ -143,22 +210,52 @@ describe('TagEvaluationService.onDepositCompleted (real PG)', () => {
143
210
  });
144
211
  });
145
212
  describe('TagEvaluationService.onWithdrawalCompleted (real PG)', () => {
146
- it('assigns high_risk when a single withdrawal meets the amount threshold', async () => {
213
+ it('assigns high_risk when a single withdrawal meets the amount threshold, recording the amount breach detail', async () => {
147
214
  const userId = randomUUID();
148
215
  const { service } = makeServices();
149
- await seedRule('high_risk', { threshold: '1000' });
216
+ const highRisk = await seedRule('high_risk', { threshold: '1000' });
150
217
  await service.onWithdrawalCompleted(withdrawalPayload(userId, 1500));
151
218
  expect(await activeTagKeys(userId)).toContain('high_risk');
219
+ const row = await assignmentRow(userId, highRisk);
220
+ expect(row?.assignMetadata).toEqual({
221
+ amountBreach: { amount: '1500', threshold: '1000.00' },
222
+ countBreach: null,
223
+ });
152
224
  });
153
- it('assigns high_risk when the withdrawal count in the window meets the threshold', async () => {
225
+ it('assigns high_risk when the withdrawal count in the window meets the threshold, recording the count breach detail', async () => {
154
226
  const userId = randomUUID();
155
227
  const { service, walletReader } = makeServices({
156
228
  walletReader: { getWithdrawalCountInWindow: vi.fn().mockResolvedValue(3) },
157
229
  });
158
- await seedRule('high_risk', { threshold: '1000', thresholdDays: 7, thresholdCount: 3 });
230
+ const highRisk = await seedRule('high_risk', {
231
+ threshold: '1000',
232
+ thresholdDays: 7,
233
+ thresholdCount: 3,
234
+ });
159
235
  await service.onWithdrawalCompleted(withdrawalPayload(userId, 10));
160
236
  expect(walletReader.getWithdrawalCountInWindow).toHaveBeenCalledWith(userId, 7);
161
- expect(await activeTagKeys(userId)).toContain('high_risk');
237
+ const row = await assignmentRow(userId, highRisk);
238
+ expect(row?.assignMetadata).toEqual({
239
+ amountBreach: null,
240
+ countBreach: { count: 3, thresholdCount: 3, thresholdDays: 7 },
241
+ });
242
+ });
243
+ it('records both breach details when both amount and frequency thresholds are met', async () => {
244
+ const userId = randomUUID();
245
+ const { service } = makeServices({
246
+ walletReader: { getWithdrawalCountInWindow: vi.fn().mockResolvedValue(5) },
247
+ });
248
+ const highRisk = await seedRule('high_risk', {
249
+ threshold: '1000',
250
+ thresholdDays: 7,
251
+ thresholdCount: 3,
252
+ });
253
+ await service.onWithdrawalCompleted(withdrawalPayload(userId, 1500));
254
+ const row = await assignmentRow(userId, highRisk);
255
+ expect(row?.assignMetadata).toEqual({
256
+ amountBreach: { amount: '1500', threshold: '1000.00' },
257
+ countBreach: { count: 5, thresholdCount: 3, thresholdDays: 7 },
258
+ });
162
259
  });
163
260
  it('takes no action when neither threshold is met (risk designation requires admin clear)', async () => {
164
261
  const userId = randomUUID();
@@ -177,17 +274,150 @@ describe('TagEvaluationService.onWithdrawalCompleted (real PG)', () => {
177
274
  expect(walletReader.getWithdrawalCountInWindow).not.toHaveBeenCalled();
178
275
  });
179
276
  });
277
+ describe('TagEvaluationService.onWithdrawalRequested (real PG)', () => {
278
+ it('assigns withdrawal_review when the withdrawal amount meets the threshold', async () => {
279
+ const userId = randomUUID();
280
+ const { service } = makeServices();
281
+ await seedRule('withdrawal_review', { threshold: '5000' });
282
+ await service.onWithdrawalRequested(withdrawalPayload(userId, 5000));
283
+ expect(await activeTagKeys(userId)).toContain('withdrawal_review');
284
+ });
285
+ it('does not assign withdrawal_review when the amount is below threshold', async () => {
286
+ const userId = randomUUID();
287
+ const { service } = makeServices();
288
+ await seedRule('withdrawal_review', { threshold: '5000' });
289
+ await service.onWithdrawalRequested(withdrawalPayload(userId, 4999));
290
+ expect(await activeTagKeys(userId)).not.toContain('withdrawal_review');
291
+ });
292
+ it('does nothing when the rule is disabled', async () => {
293
+ const userId = randomUUID();
294
+ const { service } = makeServices();
295
+ await seedRule('withdrawal_review', { threshold: '5000', isEnabled: false });
296
+ await service.onWithdrawalRequested(withdrawalPayload(userId, 99999));
297
+ expect(await activeTagKeys(userId)).toEqual([]);
298
+ });
299
+ it('does nothing when the rule is absent', async () => {
300
+ const userId = randomUUID();
301
+ const { service } = makeServices();
302
+ await service.onWithdrawalRequested(withdrawalPayload(userId, 99999));
303
+ expect(await activeTagKeys(userId)).toEqual([]);
304
+ });
305
+ });
306
+ describe('TagEvaluationService.evaluateWithdrawalRequested (real PG)', () => {
307
+ it('assigns withdrawal_review on the caller-supplied tx when the amount meets threshold', async () => {
308
+ const userId = randomUUID();
309
+ const { service } = makeServices();
310
+ await seedRule('withdrawal_review', { threshold: '5000' });
311
+ await db.drizzle.db.transaction(async (trx) => {
312
+ await service.evaluateWithdrawalRequested(trx, { userId, amount: '5000' });
313
+ });
314
+ expect(await activeTagKeys(userId)).toContain('withdrawal_review');
315
+ });
316
+ it('does not assign when the amount is below threshold', async () => {
317
+ const userId = randomUUID();
318
+ const { service } = makeServices();
319
+ await seedRule('withdrawal_review', { threshold: '5000' });
320
+ await db.drizzle.db.transaction(async (trx) => {
321
+ await service.evaluateWithdrawalRequested(trx, { userId, amount: '4999' });
322
+ });
323
+ expect(await activeTagKeys(userId)).not.toContain('withdrawal_review');
324
+ });
325
+ it('does nothing when the rule is disabled or absent', async () => {
326
+ const userId = randomUUID();
327
+ const { service } = makeServices();
328
+ await db.drizzle.db.transaction(async (trx) => {
329
+ await service.evaluateWithdrawalRequested(trx, { userId, amount: '99999' });
330
+ });
331
+ expect(await activeTagKeys(userId)).toEqual([]);
332
+ });
333
+ it('is idempotent - swallows TagAlreadyInUseError when already assigned', async () => {
334
+ const userId = randomUUID();
335
+ const { service } = makeServices();
336
+ await seedRule('withdrawal_review', { threshold: '5000' });
337
+ await expect(db.drizzle.db.transaction(async (trx) => {
338
+ await service.evaluateWithdrawalRequested(trx, { userId, amount: '5000' });
339
+ await service.evaluateWithdrawalRequested(trx, { userId, amount: '5000' });
340
+ })).resolves.toBeUndefined();
341
+ const rows = await db.drizzle.db.select().from(playerTag);
342
+ expect(rows).toHaveLength(1);
343
+ });
344
+ it('propagates an unexpected error (fail-closed: a review-gate failure must block the caller, not silently skip review)', async () => {
345
+ const userId = randomUUID();
346
+ const { service, tagService } = makeServices();
347
+ await seedRule('withdrawal_review', { threshold: '5000' });
348
+ vi.spyOn(tagService, 'assignPlayerTagInTx').mockRejectedValueOnce(new Error('db unavailable'));
349
+ await expect(db.drizzle.db.transaction(async (trx) => {
350
+ await service.evaluateWithdrawalRequested(trx, { userId, amount: '5000' });
351
+ })).rejects.toThrow('db unavailable');
352
+ });
353
+ });
180
354
  describe('TagEvaluationService.onUserLogin (real PG)', () => {
181
355
  it('removes an active inactive-tag assignment when the player logs in', async () => {
182
356
  const userId = randomUUID();
183
357
  const { service } = makeServices();
184
- const inactiveTag = await seedTagRow('inactive');
358
+ const { inactiveTag } = await seedLoginCleanupTags();
185
359
  await seedActiveAssignment(userId, inactiveTag);
186
360
  await service.onUserLogin({ userId });
187
361
  expect(await activeTagKeys(userId)).not.toContain('inactive');
188
362
  const row = await assignmentRow(userId, inactiveTag);
189
363
  expect(row).toMatchObject({ removalActor: 'scheduled', removalActorUserId: SYSTEM_ACTOR_ID });
190
364
  });
365
+ it('removes an active dormant_high_roller assignment when the player logs in', async () => {
366
+ const userId = randomUUID();
367
+ const { service } = makeServices();
368
+ const { dormantTag } = await seedLoginCleanupTags();
369
+ await seedActiveAssignment(userId, dormantTag);
370
+ await service.onUserLogin({ userId });
371
+ expect(await activeTagKeys(userId)).not.toContain('dormant_high_roller');
372
+ });
373
+ it('is idempotent when dormant_high_roller was never assigned', async () => {
374
+ const userId = randomUUID();
375
+ const { service } = makeServices();
376
+ await seedLoginCleanupTags();
377
+ await expect(service.onUserLogin({ userId })).resolves.toBeUndefined();
378
+ });
379
+ it('assigns multi_account and bonus_abuser to both player accounts when login IP is shared', async () => {
380
+ const userId = randomUUID();
381
+ const otherUserId = randomUUID();
382
+ const { service, identityReader } = makeServices({
383
+ identityReader: { getPlayerUserIdsSharingLoginIp: vi.fn().mockResolvedValue([otherUserId]) },
384
+ });
385
+ await seedLoginCleanupTags();
386
+ await seedRule('multi_account');
387
+ await seedRule('bonus_abuser');
388
+ await service.onUserLogin({ userId, ip: '203.0.113.10', userAgent: null });
389
+ expect(identityReader.getPlayerUserIdsSharingLoginIp).toHaveBeenCalledWith(userId, '203.0.113.10');
390
+ expect(await activeTagKeys(userId)).toEqual(expect.arrayContaining(['multi_account', 'bonus_abuser']));
391
+ expect(await activeTagKeys(otherUserId)).toEqual(expect.arrayContaining(['multi_account', 'bonus_abuser']));
392
+ });
393
+ it('does not evaluate shared-IP risk when login has no IP metadata', async () => {
394
+ const userId = randomUUID();
395
+ const { service, identityReader } = makeServices();
396
+ await seedLoginCleanupTags();
397
+ await seedRule('multi_account');
398
+ await seedRule('bonus_abuser');
399
+ await service.onUserLogin({ userId });
400
+ expect(identityReader.getPlayerUserIdsSharingLoginIp).not.toHaveBeenCalled();
401
+ });
402
+ it('uses the same shared-IP risk path for phone login', async () => {
403
+ const userId = randomUUID();
404
+ const otherUserId = randomUUID();
405
+ const { service, identityReader } = makeServices({
406
+ identityReader: { getPlayerUserIdsSharingLoginIp: vi.fn().mockResolvedValue([otherUserId]) },
407
+ });
408
+ await seedLoginCleanupTags();
409
+ await seedRule('multi_account');
410
+ await seedRule('bonus_abuser');
411
+ await service.onUserPhoneLogin({
412
+ userId,
413
+ method: 'phone',
414
+ ip: '203.0.113.11',
415
+ userAgent: null,
416
+ });
417
+ expect(identityReader.getPlayerUserIdsSharingLoginIp).toHaveBeenCalledWith(userId, '203.0.113.11');
418
+ expect(await activeTagKeys(userId)).toContain('multi_account');
419
+ expect(await activeTagKeys(otherUserId)).toContain('bonus_abuser');
420
+ });
191
421
  });
192
422
  describe('TagEvaluationService.onKycSubmitted (real PG)', () => {
193
423
  it('assigns kyc_pending when the rule is enabled', async () => {
@@ -197,14 +427,23 @@ describe('TagEvaluationService.onKycSubmitted (real PG)', () => {
197
427
  await service.onKycSubmitted({ userId, referenceId: 'ref-1', provider: 'sumsub' });
198
428
  expect(await activeTagKeys(userId)).toContain('kyc_pending');
199
429
  });
200
- it('removes an existing kyc_rejected assignment on resubmission', async () => {
430
+ it('keeps kyc_rejected sticky on the resubmission path', async () => {
201
431
  const userId = randomUUID();
202
432
  const { service } = makeServices();
203
433
  const { kycRejectedTag } = await seedKycPendingRule();
204
434
  await seedActiveAssignment(userId, kycRejectedTag);
205
435
  await service.onKycSubmitted({ userId, referenceId: 'ref-1', provider: 'sumsub' });
206
436
  expect(await activeTagKeys(userId)).toContain('kyc_pending');
207
- expect(await activeTagKeys(userId)).not.toContain('kyc_rejected');
437
+ expect(await activeTagKeys(userId)).toContain('kyc_rejected');
438
+ });
439
+ it('does not assign kyc_pending when the profile status is already terminal', async () => {
440
+ const userId = randomUUID();
441
+ const { service } = makeServices({
442
+ identityReader: { getPlayerKycStatusByUserId: vi.fn().mockResolvedValue('verified') },
443
+ });
444
+ await seedKycPendingRule();
445
+ await service.onKycSubmitted({ userId, referenceId: 'ref-1', provider: 'sumsub' });
446
+ expect(await activeTagKeys(userId)).toEqual([]);
208
447
  });
209
448
  it('does nothing when kyc_pending rule is disabled', async () => {
210
449
  const userId = randomUUID();
@@ -243,17 +482,35 @@ describe('TagEvaluationService.onKycStatusUpdated (real PG)', () => {
243
482
  await service.onKycStatusUpdated(kycUpdatedPayload(userId, 'rejected'));
244
483
  expect(await activeTagKeys(userId)).toEqual(['kyc_rejected']);
245
484
  });
246
- it('assigns kyc_pending on resubmission_requested', async () => {
485
+ it('assigns kyc_pending and keeps kyc_rejected on resubmission_requested', async () => {
247
486
  const userId = randomUUID();
248
487
  const { service } = makeServices();
249
- await seedKycPendingRule();
488
+ const { kycRejectedTag } = await seedKycPendingRule();
489
+ await seedActiveAssignment(userId, kycRejectedTag);
250
490
  await service.onKycStatusUpdated(kycUpdatedPayload(userId, 'resubmission_requested'));
251
- expect(await activeTagKeys(userId)).toContain('kyc_pending');
491
+ expect(await activeTagKeys(userId)).toEqual(expect.arrayContaining(['kyc_pending', 'kyc_rejected']));
492
+ });
493
+ it('does not assign kyc_pending on resubmission_requested when the rule is disabled', async () => {
494
+ const userId = randomUUID();
495
+ const { service } = makeServices();
496
+ await seedRule('kyc_pending', { isEnabled: false });
497
+ await service.onKycStatusUpdated(kycUpdatedPayload(userId, 'resubmission_requested'));
498
+ expect(await activeTagKeys(userId)).toEqual([]);
499
+ });
500
+ it('does not assign kyc_rejected when the rejected rule is disabled', async () => {
501
+ const userId = randomUUID();
502
+ const { service } = makeServices();
503
+ const kycPendingTag = await seedRule('kyc_pending');
504
+ await seedActiveAssignment(userId, kycPendingTag);
505
+ await seedRule('kyc_rejected', { isEnabled: false });
506
+ await service.onKycStatusUpdated(kycUpdatedPayload(userId, 'rejected'));
507
+ expect(await activeTagKeys(userId)).toEqual([]);
252
508
  });
253
509
  it('does nothing when kyc_pending rule is disabled', async () => {
254
510
  const userId = randomUUID();
255
511
  const { service } = makeServices();
256
512
  await seedRule('kyc_pending', { isEnabled: false });
513
+ await seedTagRow('kyc_rejected');
257
514
  await service.onKycStatusUpdated(kycUpdatedPayload(userId, 'verified'));
258
515
  expect(await activeTagKeys(userId)).toEqual([]);
259
516
  });
@@ -318,6 +575,349 @@ describe('TagEvaluationService.runDailyEvaluation (real PG)', () => {
318
575
  await service.runDailyEvaluation();
319
576
  expect(identityReader.getPlayerIdsInactiveSince).not.toHaveBeenCalled();
320
577
  });
578
+ describe('dormant_high_roller sweep (real PG)', () => {
579
+ it('assigns to inactive users who also hold high_roller', async () => {
580
+ const { account: a1, player: p1 } = await seedPlayerWithUser();
581
+ const { account: a2, player: p2 } = await seedPlayerWithUser();
582
+ const highRoller = await seedTagRow('high_roller');
583
+ await seedActiveAssignment(p1.id, highRoller);
584
+ const kycPending = await seedTagRow('kyc_pending');
585
+ await seedActiveAssignment(p2.id, kycPending);
586
+ await seedRule('dormant_high_roller', { thresholdDays: 60 });
587
+ const { service } = makeServices({
588
+ identityReader: {
589
+ getPlayerIdsInactiveSince: vi.fn().mockResolvedValue([a1.id, a2.id]),
590
+ getPlayerIdByUserId: identityLookup([
591
+ [a1.id, p1.id],
592
+ [a2.id, p2.id],
593
+ ]),
594
+ },
595
+ });
596
+ await service.runDailyEvaluation();
597
+ expect(await activeTagKeys(p1.id)).toContain('dormant_high_roller');
598
+ expect(await activeTagKeys(p2.id)).not.toContain('dormant_high_roller');
599
+ });
600
+ it('does not assign to inactive users without high_roller', async () => {
601
+ const { account, player: seededPlayer } = await seedPlayerWithUser();
602
+ const vip = await seedTagRow('vip');
603
+ await seedActiveAssignment(seededPlayer.id, vip);
604
+ await seedRule('dormant_high_roller', { thresholdDays: 60 });
605
+ const { service } = makeServices({
606
+ identityReader: {
607
+ getPlayerIdsInactiveSince: vi.fn().mockResolvedValue([account.id]),
608
+ getPlayerIdByUserId: identityLookup([[account.id, seededPlayer.id]]),
609
+ },
610
+ });
611
+ await service.runDailyEvaluation();
612
+ expect(await activeTagKeys(seededPlayer.id)).not.toContain('dormant_high_roller');
613
+ });
614
+ it('does nothing when the rule is disabled', async () => {
615
+ await seedRule('dormant_high_roller', { thresholdDays: 60, isEnabled: false });
616
+ const { service, identityReader } = makeServices();
617
+ await service.runDailyEvaluation();
618
+ expect(identityReader.getPlayerIdsInactiveSince).not.toHaveBeenCalled();
619
+ });
620
+ it('does nothing when thresholdDays is null', async () => {
621
+ await seedRule('dormant_high_roller', { thresholdDays: null });
622
+ const { service, identityReader } = makeServices();
623
+ await service.runDailyEvaluation();
624
+ expect(identityReader.getPlayerIdsInactiveSince).not.toHaveBeenCalled();
625
+ });
626
+ });
627
+ describe('high_risk resweep (real PG)', () => {
628
+ const countOnlyMetadata = {
629
+ amountBreach: null,
630
+ countBreach: { count: 3, thresholdCount: 3, thresholdDays: 7 },
631
+ };
632
+ const amountBreachMetadata = {
633
+ amountBreach: { amount: '2000', threshold: '1000' },
634
+ countBreach: null,
635
+ };
636
+ async function seedHighRiskHolder(highRiskTag, metadata) {
637
+ const { account, player: seededPlayer } = await seedPlayerWithUser();
638
+ await seedActiveAssignment(seededPlayer.id, highRiskTag, { assignMetadata: metadata });
639
+ return { account, player: seededPlayer };
640
+ }
641
+ it('removes the tag from a holder when the recheck shows count below threshold', async () => {
642
+ const highRisk = await seedRule('high_risk', {
643
+ threshold: '1000',
644
+ thresholdDays: 7,
645
+ thresholdCount: 3,
646
+ });
647
+ const { account, player: seededPlayer } = await seedHighRiskHolder(highRisk, countOnlyMetadata);
648
+ const { service, walletReader } = makeServices({
649
+ walletReader: {
650
+ getWithdrawalCountsInWindow: vi.fn().mockResolvedValue(new Map([[account.id, 1]])),
651
+ },
652
+ identityReader: { getPlayerIdByUserId: identityLookup([[account.id, seededPlayer.id]]) },
653
+ });
654
+ await service.runDailyEvaluation();
655
+ expect(walletReader.getWithdrawalCountsInWindow).toHaveBeenCalledWith([account.id], 7);
656
+ expect(await activeTagKeys(seededPlayer.id)).not.toContain('high_risk');
657
+ });
658
+ it('keeps the tag when the count is still at or above threshold', async () => {
659
+ const highRisk = await seedRule('high_risk', {
660
+ threshold: '1000',
661
+ thresholdDays: 7,
662
+ thresholdCount: 3,
663
+ });
664
+ const { account, player: seededPlayer } = await seedHighRiskHolder(highRisk, countOnlyMetadata);
665
+ const { service } = makeServices({
666
+ walletReader: {
667
+ getWithdrawalCountsInWindow: vi.fn().mockResolvedValue(new Map([[account.id, 3]])),
668
+ },
669
+ identityReader: { getPlayerIdByUserId: identityLookup([[account.id, seededPlayer.id]]) },
670
+ });
671
+ await service.runDailyEvaluation();
672
+ expect(await activeTagKeys(seededPlayer.id)).toContain('high_risk');
673
+ });
674
+ it('treats a holder absent from the batched result as zero and removes the tag', async () => {
675
+ const highRisk = await seedRule('high_risk', {
676
+ threshold: '1000',
677
+ thresholdDays: 7,
678
+ thresholdCount: 3,
679
+ });
680
+ const { account, player: seededPlayer } = await seedHighRiskHolder(highRisk, countOnlyMetadata);
681
+ const { service } = makeServices({
682
+ walletReader: { getWithdrawalCountsInWindow: vi.fn().mockResolvedValue(new Map()) },
683
+ identityReader: { getPlayerIdByUserId: identityLookup([[account.id, seededPlayer.id]]) },
684
+ });
685
+ await service.runDailyEvaluation();
686
+ expect(await activeTagKeys(seededPlayer.id)).not.toContain('high_risk');
687
+ });
688
+ it('skips entirely when thresholdDays is null (amount-only rule, not resweepable)', async () => {
689
+ await seedRule('high_risk', { threshold: '1000', thresholdDays: null, thresholdCount: 3 });
690
+ const { service, walletReader } = makeServices();
691
+ await service.runDailyEvaluation();
692
+ expect(walletReader.getWithdrawalCountsInWindow).not.toHaveBeenCalled();
693
+ });
694
+ it('skips entirely when thresholdCount is null (no frequency dimension to resweep)', async () => {
695
+ await seedRule('high_risk', { threshold: '1000', thresholdDays: 7, thresholdCount: null });
696
+ const { service, walletReader } = makeServices();
697
+ await service.runDailyEvaluation();
698
+ expect(walletReader.getWithdrawalCountsInWindow).not.toHaveBeenCalled();
699
+ });
700
+ it('skips when the rule is disabled', async () => {
701
+ await seedRule('high_risk', {
702
+ threshold: '1000',
703
+ thresholdDays: 7,
704
+ thresholdCount: 3,
705
+ isEnabled: false,
706
+ });
707
+ const { service, walletReader } = makeServices();
708
+ await service.runDailyEvaluation();
709
+ expect(walletReader.getWithdrawalCountsInWindow).not.toHaveBeenCalled();
710
+ });
711
+ it('does nothing when there are no holders', async () => {
712
+ await seedRule('high_risk', { threshold: '1000', thresholdDays: 7, thresholdCount: 3 });
713
+ const { service, walletReader } = makeServices();
714
+ await service.runDailyEvaluation();
715
+ expect(walletReader.getWithdrawalCountsInWindow).not.toHaveBeenCalled();
716
+ });
717
+ it('falls back to per-holder getWithdrawalCountInWindow when the batched method is absent from the adapter', async () => {
718
+ const highRisk = await seedRule('high_risk', {
719
+ threshold: '1000',
720
+ thresholdDays: 7,
721
+ thresholdCount: 3,
722
+ });
723
+ const { account: a1, player: p1 } = await seedHighRiskHolder(highRisk, countOnlyMetadata);
724
+ const { account: a2, player: p2 } = await seedHighRiskHolder(highRisk, countOnlyMetadata);
725
+ const { service, walletReader } = makeServices({
726
+ // Simulate an external WalletReader implementation that predates the batched method.
727
+ walletReader: {
728
+ getWithdrawalCountsInWindow: undefined,
729
+ getWithdrawalCountInWindow: vi.fn(async (userId) => (userId === a1.id ? 1 : 5)),
730
+ },
731
+ identityReader: {
732
+ getPlayerIdByUserId: identityLookup([
733
+ [a1.id, p1.id],
734
+ [a2.id, p2.id],
735
+ ]),
736
+ },
737
+ });
738
+ await service.runDailyEvaluation();
739
+ expect(walletReader.getWithdrawalCountInWindow).toHaveBeenCalledWith(a1.id, 7);
740
+ expect(walletReader.getWithdrawalCountInWindow).toHaveBeenCalledWith(a2.id, 7);
741
+ expect(await activeTagKeys(p1.id)).not.toContain('high_risk');
742
+ expect(await activeTagKeys(p2.id)).toContain('high_risk');
743
+ });
744
+ it('does not remove a holder whose assignMetadata shows an amount breach, even when the windowed count is below threshold (bug-fix case)', async () => {
745
+ const highRisk = await seedRule('high_risk', {
746
+ threshold: '1000',
747
+ thresholdDays: 7,
748
+ thresholdCount: 3,
749
+ });
750
+ const { account, player: seededPlayer } = await seedHighRiskHolder(highRisk, amountBreachMetadata);
751
+ const { service } = makeServices({
752
+ walletReader: {
753
+ getWithdrawalCountsInWindow: vi.fn().mockResolvedValue(new Map([[account.id, 0]])),
754
+ },
755
+ identityReader: { getPlayerIdByUserId: identityLookup([[account.id, seededPlayer.id]]) },
756
+ });
757
+ await service.runDailyEvaluation();
758
+ expect(await activeTagKeys(seededPlayer.id)).toContain('high_risk');
759
+ });
760
+ it('still removes a count-only-breach holder whose windowed count drops below threshold (existing behavior preserved)', async () => {
761
+ const highRisk = await seedRule('high_risk', {
762
+ threshold: '1000',
763
+ thresholdDays: 7,
764
+ thresholdCount: 3,
765
+ });
766
+ const { account, player: seededPlayer } = await seedHighRiskHolder(highRisk, countOnlyMetadata);
767
+ const { service } = makeServices({
768
+ walletReader: {
769
+ getWithdrawalCountsInWindow: vi.fn().mockResolvedValue(new Map([[account.id, 1]])),
770
+ },
771
+ identityReader: { getPlayerIdByUserId: identityLookup([[account.id, seededPlayer.id]]) },
772
+ });
773
+ await service.runDailyEvaluation();
774
+ expect(await activeTagKeys(seededPlayer.id)).not.toContain('high_risk');
775
+ });
776
+ it('does not remove a holder with null assignMetadata (legacy row), even when below threshold', async () => {
777
+ const highRisk = await seedRule('high_risk', {
778
+ threshold: '1000',
779
+ thresholdDays: 7,
780
+ thresholdCount: 3,
781
+ });
782
+ const { account, player: seededPlayer } = await seedHighRiskHolder(highRisk, null);
783
+ const { service } = makeServices({
784
+ walletReader: {
785
+ getWithdrawalCountsInWindow: vi.fn().mockResolvedValue(new Map([[account.id, 0]])),
786
+ },
787
+ identityReader: { getPlayerIdByUserId: identityLookup([[account.id, seededPlayer.id]]) },
788
+ });
789
+ await service.runDailyEvaluation();
790
+ expect(await activeTagKeys(seededPlayer.id)).toContain('high_risk');
791
+ });
792
+ });
793
+ });
794
+ describe('TagEvaluationService.onSelfExclusionActivated / onSelfExclusionLifted (real PG)', () => {
795
+ it('assigns self_excluded', async () => {
796
+ const userId = randomUUID();
797
+ const { service } = makeServices();
798
+ await seedTagRow('self_excluded');
799
+ await service.onSelfExclusionActivated({
800
+ userId,
801
+ actorId: SYSTEM_ACTOR_ID,
802
+ reason: 'player requested self-exclusion',
803
+ exclusionId: randomUUID(),
804
+ isPermanent: false,
805
+ durationMonths: 6,
806
+ expiresAt: new Date().toISOString(),
807
+ });
808
+ expect(await activeTagKeys(userId)).toContain('self_excluded');
809
+ });
810
+ it('is idempotent when self_excluded is already assigned', async () => {
811
+ const userId = randomUUID();
812
+ const { service } = makeServices();
813
+ const selfExcluded = await seedTagRow('self_excluded');
814
+ await seedActiveAssignment(userId, selfExcluded);
815
+ await expect(service.onSelfExclusionActivated({
816
+ userId,
817
+ actorId: SYSTEM_ACTOR_ID,
818
+ reason: 'player requested self-exclusion',
819
+ exclusionId: randomUUID(),
820
+ isPermanent: false,
821
+ durationMonths: 6,
822
+ expiresAt: null,
823
+ })).resolves.toBeUndefined();
824
+ });
825
+ it('removes self_excluded', async () => {
826
+ const userId = randomUUID();
827
+ const { service } = makeServices();
828
+ const selfExcluded = await seedTagRow('self_excluded');
829
+ await seedActiveAssignment(userId, selfExcluded);
830
+ await service.onSelfExclusionLifted({
831
+ userId,
832
+ actorId: SYSTEM_ACTOR_ID,
833
+ reason: 'exclusion period expired',
834
+ exclusionId: randomUUID(),
835
+ kind: 'self_exclusion',
836
+ });
837
+ expect(await activeTagKeys(userId)).not.toContain('self_excluded');
838
+ });
839
+ it('is idempotent when self_excluded was never assigned', async () => {
840
+ const userId = randomUUID();
841
+ const { service } = makeServices();
842
+ await seedTagRow('self_excluded');
843
+ await expect(service.onSelfExclusionLifted({
844
+ userId,
845
+ actorId: SYSTEM_ACTOR_ID,
846
+ reason: 'exclusion period expired',
847
+ exclusionId: randomUUID(),
848
+ kind: 'self_exclusion',
849
+ })).resolves.toBeUndefined();
850
+ });
851
+ });
852
+ describe('TagEvaluationService.onPlayerLevelChanged (real PG)', () => {
853
+ it('atomically replaces the level tag with the new level in the assign reason', async () => {
854
+ const userId = randomUUID();
855
+ const { service } = makeServices();
856
+ const levelTag = await seedTagRow('level');
857
+ await seedActiveAssignment(userId, levelTag, { assignReason: 'player level set to 3' });
858
+ await service.onPlayerLevelChanged({
859
+ userId,
860
+ previousLevel: 3,
861
+ newLevel: 4,
862
+ actorId: SYSTEM_ACTOR_ID,
863
+ });
864
+ const row = await activeAssignmentRow(userId, levelTag);
865
+ expect(row).toMatchObject({
866
+ assignReason: 'player level set to 4',
867
+ assignActor: 'scheduled',
868
+ assignActorUserId: SYSTEM_ACTOR_ID,
869
+ });
870
+ });
871
+ it('swallows TagAlreadyInUseError when a concurrent replace wins the race', async () => {
872
+ const userId = randomUUID();
873
+ const { service } = makeServices();
874
+ const levelTag = await seedTagRow('level');
875
+ await expect(Promise.all([
876
+ service.onPlayerLevelChanged({
877
+ userId,
878
+ previousLevel: 0,
879
+ newLevel: 1,
880
+ actorId: SYSTEM_ACTOR_ID,
881
+ }),
882
+ service.onPlayerLevelChanged({
883
+ userId,
884
+ previousLevel: 0,
885
+ newLevel: 2,
886
+ actorId: SYSTEM_ACTOR_ID,
887
+ }),
888
+ ])).resolves.toEqual([undefined, undefined]);
889
+ const rows = await db.drizzle.db
890
+ .select()
891
+ .from(playerTag)
892
+ .where(and(eq(playerTag.playerId, userId), eq(playerTag.tagId, levelTag.id), isNull(playerTag.removedAt)));
893
+ expect(rows).toHaveLength(1);
894
+ });
895
+ it('rethrows unexpected errors from the replace', async () => {
896
+ const userId = randomUUID();
897
+ const { service, tagService } = makeServices();
898
+ await seedTagRow('level');
899
+ vi.spyOn(tagService, 'replacePlayerTag').mockRejectedValueOnce(new Error('connection lost'));
900
+ await expect(service.onPlayerLevelChanged({
901
+ userId,
902
+ previousLevel: 0,
903
+ newLevel: 1,
904
+ actorId: SYSTEM_ACTOR_ID,
905
+ })).rejects.toThrow('connection lost');
906
+ });
907
+ it('is a no-op when the playerId is unknown to the identity reader', async () => {
908
+ const userId = randomUUID();
909
+ const { service, tagService } = makeServices({
910
+ identityReader: { getPlayerIdByUserId: vi.fn().mockResolvedValue(null) },
911
+ });
912
+ const replaceSpy = vi.spyOn(tagService, 'replacePlayerTag');
913
+ await expect(service.onPlayerLevelChanged({
914
+ userId,
915
+ previousLevel: 3,
916
+ newLevel: 4,
917
+ actorId: SYSTEM_ACTOR_ID,
918
+ })).resolves.toBeUndefined();
919
+ expect(replaceSpy).not.toHaveBeenCalled();
920
+ });
321
921
  });
322
922
  describe('TagEvaluationService idempotency (real PG)', () => {
323
923
  it('swallows TagAlreadyInUseError when the tag is already active', async () => {
@@ -335,7 +935,7 @@ describe('TagEvaluationService idempotency (real PG)', () => {
335
935
  it('swallows TagAssignmentNotFoundError when nothing is assigned to remove', async () => {
336
936
  const userId = randomUUID();
337
937
  const { service } = makeServices();
338
- await seedTagRow('inactive');
938
+ await seedLoginCleanupTags();
339
939
  await expect(service.onUserLogin({ userId })).resolves.toBeUndefined();
340
940
  expect(await activeTagKeys(userId)).toEqual([]);
341
941
  });