@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/audit/plugin.d.ts.map +1 -1
- package/dist/audit/plugin.js +27 -0
- package/dist/audit/plugin.js.map +1 -1
- package/dist/common/errors/errors.classes.js +3 -3
- package/dist/common/errors/errors.classes.js.map +1 -1
- package/dist/common/errors/errors.mapper.js +1 -1
- package/dist/common/errors/errors.mapper.js.map +1 -1
- package/dist/compliance/__tests__/kyc.service.test.js +5 -0
- package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
- package/dist/compliance/service/kyc.service.js +1 -1
- package/dist/compliance/service/kyc.service.js.map +1 -1
- package/dist/contracts/adapters/identity-reader.d.ts +9 -3
- package/dist/contracts/adapters/identity-reader.d.ts.map +1 -1
- package/dist/contracts/adapters/identity-reader.js.map +1 -1
- package/dist/contracts/adapters/index.d.ts +2 -0
- package/dist/contracts/adapters/index.d.ts.map +1 -1
- package/dist/contracts/adapters/index.js +1 -0
- package/dist/contracts/adapters/index.js.map +1 -1
- package/dist/contracts/adapters/tag-evaluation-commands.d.ts +10 -0
- package/dist/contracts/adapters/tag-evaluation-commands.d.ts.map +1 -0
- package/dist/contracts/adapters/tag-evaluation-commands.js +11 -0
- package/dist/contracts/adapters/tag-evaluation-commands.js.map +1 -0
- package/dist/contracts/adapters/wallet-reader.d.ts +8 -0
- package/dist/contracts/adapters/wallet-reader.d.ts.map +1 -1
- package/dist/contracts/adapters/wallet-reader.js.map +1 -1
- package/dist/contracts/schemas/events.d.ts +57 -0
- package/dist/contracts/schemas/events.d.ts.map +1 -1
- package/dist/contracts/schemas/events.js +12 -0
- package/dist/contracts/schemas/events.js.map +1 -1
- package/dist/contracts/schemas/platform-config.d.ts +9 -1
- package/dist/contracts/schemas/platform-config.d.ts.map +1 -1
- package/dist/contracts/schemas/player.d.ts +10 -2
- package/dist/contracts/schemas/player.d.ts.map +1 -1
- package/dist/contracts/schemas/tag.d.ts +65 -1
- package/dist/contracts/schemas/tag.d.ts.map +1 -1
- package/dist/contracts/schemas/tag.js +10 -0
- package/dist/contracts/schemas/tag.js.map +1 -1
- package/dist/iam/service/iam.service.d.ts +1 -1
- package/dist/pam/identity/adapters/identity-reader.service.d.ts +6 -4
- package/dist/pam/identity/adapters/identity-reader.service.d.ts.map +1 -1
- package/dist/pam/identity/adapters/identity-reader.service.js +19 -1
- package/dist/pam/identity/adapters/identity-reader.service.js.map +1 -1
- package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
- package/dist/pam/identity/service/identity.service.js +4 -0
- package/dist/pam/identity/service/identity.service.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.router.test.js +2 -2
- package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.service.test.js +28 -2
- package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
- package/dist/pam/player-management/contract/index.d.ts +21 -1
- package/dist/pam/player-management/contract/index.d.ts.map +1 -1
- package/dist/pam/player-management/plugin.js +1 -1
- package/dist/pam/player-management/plugin.js.map +1 -1
- package/dist/pam/player-management/router/index.d.ts +17 -1
- package/dist/pam/player-management/router/index.d.ts.map +1 -1
- package/dist/pam/player-management/service/player.service.d.ts +3 -1
- package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
- package/dist/pam/player-management/service/player.service.js +14 -1
- package/dist/pam/player-management/service/player.service.js.map +1 -1
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +600 -17
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag-mappers.test.js +25 -0
- package/dist/pam/tag/__tests__/tag-mappers.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.router.test.js +71 -1
- package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.service.test.js +342 -15
- package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
- package/dist/pam/tag/contract/index.d.ts +97 -0
- package/dist/pam/tag/contract/index.d.ts.map +1 -1
- package/dist/pam/tag/contract/index.js +3 -0
- package/dist/pam/tag/contract/index.js.map +1 -1
- package/dist/pam/tag/contract/player-tag-assign-metadata.d.ts +37 -0
- package/dist/pam/tag/contract/player-tag-assign-metadata.d.ts.map +1 -0
- package/dist/pam/tag/contract/player-tag-assign-metadata.js +20 -0
- package/dist/pam/tag/contract/player-tag-assign-metadata.js.map +1 -0
- package/dist/pam/tag/drizzle/migrations/0002_silent_skaar.sql +4 -0
- package/dist/pam/tag/drizzle/migrations/0003_overconfident_guardian.sql +3 -0
- package/dist/pam/tag/drizzle/migrations/meta/0002_snapshot.json +335 -0
- package/dist/pam/tag/drizzle/migrations/meta/0003_snapshot.json +343 -0
- package/dist/pam/tag/drizzle/migrations/meta/_journal.json +14 -0
- package/dist/pam/tag/migrate.d.ts.map +1 -1
- package/dist/pam/tag/migrate.js +34 -0
- package/dist/pam/tag/migrate.js.map +1 -1
- package/dist/pam/tag/plugin.d.ts.map +1 -1
- package/dist/pam/tag/plugin.js +35 -16
- package/dist/pam/tag/plugin.js.map +1 -1
- package/dist/pam/tag/router/index.d.ts +81 -0
- package/dist/pam/tag/router/index.d.ts.map +1 -1
- package/dist/pam/tag/router/index.js +34 -18
- package/dist/pam/tag/router/index.js.map +1 -1
- package/dist/pam/tag/schema/index.d.ts +42 -3
- package/dist/pam/tag/schema/index.d.ts.map +1 -1
- package/dist/pam/tag/schema/index.js +13 -2
- package/dist/pam/tag/schema/index.js.map +1 -1
- package/dist/pam/tag/seed/data/default-tag-rules.d.ts.map +1 -1
- package/dist/pam/tag/seed/data/default-tag-rules.js +28 -0
- package/dist/pam/tag/seed/data/default-tag-rules.js.map +1 -1
- package/dist/pam/tag/seed/data/default-tags.d.ts.map +1 -1
- package/dist/pam/tag/seed/data/default-tags.js +4 -0
- package/dist/pam/tag/seed/data/default-tags.js.map +1 -1
- package/dist/pam/tag/service/tag-evaluation.service.d.ts +113 -12
- package/dist/pam/tag/service/tag-evaluation.service.d.ts.map +1 -1
- package/dist/pam/tag/service/tag-evaluation.service.js +354 -35
- package/dist/pam/tag/service/tag-evaluation.service.js.map +1 -1
- package/dist/pam/tag/service/tag-mappers.d.ts +1 -1
- package/dist/pam/tag/service/tag-mappers.d.ts.map +1 -1
- package/dist/pam/tag/service/tag-mappers.js +1 -0
- package/dist/pam/tag/service/tag-mappers.js.map +1 -1
- package/dist/pam/tag/service/tag-rule.service.d.ts +3 -3
- package/dist/pam/tag/service/tag.service.d.ts +141 -11
- package/dist/pam/tag/service/tag.service.d.ts.map +1 -1
- package/dist/pam/tag/service/tag.service.js +242 -23
- package/dist/pam/tag/service/tag.service.js.map +1 -1
- package/dist/server/auth/permissions.d.ts +12 -0
- package/dist/server/auth/permissions.d.ts.map +1 -1
- package/dist/server/auth/permissions.js +2 -0
- package/dist/server/auth/permissions.js.map +1 -1
- package/dist/server/db/__tests__/migrate.test.js +79 -2
- package/dist/server/db/__tests__/migrate.test.js.map +1 -1
- package/dist/server/db/migrate.d.ts +11 -0
- package/dist/server/db/migrate.d.ts.map +1 -1
- package/dist/server/db/migrate.js +3 -0
- package/dist/server/db/migrate.js.map +1 -1
- package/dist/server/plugin-host/__tests__/core-plugins.test.js +1 -1
- package/dist/server/plugin-host/__tests__/core-plugins.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +60 -0
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.service.test.js +41 -0
- package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
- package/dist/wallet/adapters/wallet-reader.service.d.ts +1 -0
- package/dist/wallet/adapters/wallet-reader.service.d.ts.map +1 -1
- package/dist/wallet/adapters/wallet-reader.service.js +18 -1
- package/dist/wallet/adapters/wallet-reader.service.js.map +1 -1
- package/dist/wallet/plugin.d.ts.map +1 -1
- package/dist/wallet/plugin.js +7 -4
- package/dist/wallet/plugin.js.map +1 -1
- package/dist/wallet/service/wallet.service.d.ts +4 -2
- package/dist/wallet/service/wallet.service.d.ts.map +1 -1
- package/dist/wallet/service/wallet.service.js +11 -1
- package/dist/wallet/service/wallet.service.js.map +1 -1
- 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
|
|
35
|
+
const kycRejectedTag = await seedRule('kyc_rejected');
|
|
32
36
|
return { kycPendingTag, kycRejectedTag };
|
|
33
37
|
}
|
|
34
|
-
|
|
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', {
|
|
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
|
-
|
|
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
|
|
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('
|
|
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)).
|
|
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)).
|
|
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
|
|
921
|
+
await seedLoginCleanupTags();
|
|
339
922
|
await expect(service.onUserLogin({ userId })).resolves.toBeUndefined();
|
|
340
923
|
expect(await activeTagKeys(userId)).toEqual([]);
|
|
341
924
|
});
|