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