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

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