@openora/core 0.4.1-canary.37 → 0.4.1-canary.39
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/__tests__/backoffice.router.test.js +67 -51
- package/dist/admin-console/__tests__/backoffice.router.test.js.map +1 -1
- package/dist/admin-console/__tests__/backoffice.service.test.js +1 -13
- package/dist/admin-console/__tests__/backoffice.service.test.js.map +1 -1
- package/dist/analytics/__tests__/analytics.contract.test.js +12 -0
- package/dist/analytics/__tests__/analytics.contract.test.js.map +1 -1
- package/dist/analytics/__tests__/financial-analytics.service.test.js +3 -3
- package/dist/analytics/__tests__/financial-analytics.service.test.js.map +1 -1
- package/dist/analytics/service/financial-analytics.service.d.ts.map +1 -1
- package/dist/analytics/service/financial-analytics.service.js +14 -17
- package/dist/analytics/service/financial-analytics.service.js.map +1 -1
- package/dist/analytics/service/funnel-analytics.service.d.ts.map +1 -1
- package/dist/analytics/service/funnel-analytics.service.js +8 -11
- package/dist/analytics/service/funnel-analytics.service.js.map +1 -1
- package/dist/audit/__tests__/audit.service.test.js +128 -251
- package/dist/audit/__tests__/audit.service.test.js.map +1 -1
- package/dist/casino/gaming/__tests__/gaming.service.test.js +83 -89
- package/dist/casino/gaming/__tests__/gaming.service.test.js.map +1 -1
- package/dist/casino/lobby/__tests__/lobby.cache.test.js +42 -33
- package/dist/casino/lobby/__tests__/lobby.cache.test.js.map +1 -1
- package/dist/cms/__tests__/cms.reads.test.js +59 -80
- package/dist/cms/__tests__/cms.reads.test.js.map +1 -1
- package/dist/cms/__tests__/cms.router.test.js +62 -20
- package/dist/cms/__tests__/cms.router.test.js.map +1 -1
- package/dist/cms/__tests__/cms.service.test.js +116 -14
- package/dist/cms/__tests__/cms.service.test.js.map +1 -1
- package/dist/common/errors/__tests__/errors.mapper.test.d.ts +2 -0
- package/dist/common/errors/__tests__/errors.mapper.test.d.ts.map +1 -0
- package/dist/common/errors/__tests__/errors.mapper.test.js +59 -0
- package/dist/common/errors/__tests__/errors.mapper.test.js.map +1 -0
- package/dist/compliance/__tests__/assert.test.d.ts +2 -0
- package/dist/compliance/__tests__/assert.test.d.ts.map +1 -0
- package/dist/compliance/__tests__/assert.test.js +34 -0
- package/dist/compliance/__tests__/assert.test.js.map +1 -0
- package/dist/compliance/__tests__/compliance.service.test.js +149 -84
- package/dist/compliance/__tests__/compliance.service.test.js.map +1 -1
- package/dist/compliance/__tests__/kyc.service.test.js +247 -155
- package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
- package/dist/compliance/__tests__/limits.contract.test.d.ts +2 -0
- package/dist/compliance/__tests__/limits.contract.test.d.ts.map +1 -0
- package/dist/compliance/__tests__/limits.contract.test.js +43 -0
- package/dist/compliance/__tests__/limits.contract.test.js.map +1 -0
- package/dist/compliance/__tests__/rg-monitoring.service.test.js +256 -101
- package/dist/compliance/__tests__/rg-monitoring.service.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.contract.test.js +1 -20
- package/dist/compliance/__tests__/rg.contract.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.router.test.js +113 -65
- package/dist/compliance/__tests__/rg.router.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.service.test.js +277 -235
- package/dist/compliance/__tests__/rg.service.test.js.map +1 -1
- package/dist/compliance/service/rg-monitoring.service.d.ts.map +1 -1
- package/dist/compliance/service/rg-monitoring.service.js +11 -8
- package/dist/compliance/service/rg-monitoring.service.js.map +1 -1
- package/dist/contracts/__tests__/compose-contract.test.d.ts +2 -0
- package/dist/contracts/__tests__/compose-contract.test.d.ts.map +1 -0
- package/dist/contracts/__tests__/compose-contract.test.js +27 -0
- package/dist/contracts/__tests__/compose-contract.test.js.map +1 -0
- package/dist/contracts/adapters/__tests__/token.test.d.ts +2 -0
- package/dist/contracts/adapters/__tests__/token.test.d.ts.map +1 -0
- package/dist/contracts/adapters/__tests__/token.test.js +14 -0
- package/dist/contracts/adapters/__tests__/token.test.js.map +1 -0
- package/dist/contracts/schemas/__tests__/events.test.d.ts +2 -0
- package/dist/contracts/schemas/__tests__/events.test.d.ts.map +1 -0
- package/dist/contracts/schemas/__tests__/events.test.js +51 -0
- package/dist/contracts/schemas/__tests__/events.test.js.map +1 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts +2 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts.map +1 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.js +38 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.js.map +1 -0
- package/dist/contracts/schemas/reporting.d.ts +7 -2
- package/dist/contracts/schemas/reporting.d.ts.map +1 -1
- package/dist/contracts/schemas/reporting.js +2 -1
- package/dist/contracts/schemas/reporting.js.map +1 -1
- package/dist/engagement/chat/__tests__/chat.service.test.js +524 -364
- package/dist/engagement/chat/__tests__/chat.service.test.js.map +1 -1
- package/dist/engagement/notifications/__tests__/notifications.service.test.js +125 -68
- package/dist/engagement/notifications/__tests__/notifications.service.test.js.map +1 -1
- package/dist/iam/__tests__/iam.service.test.js +367 -596
- package/dist/iam/__tests__/iam.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/admin-user-directory.test.js +144 -68
- package/dist/pam/identity/__tests__/admin-user-directory.test.js.map +1 -1
- package/dist/pam/identity/__tests__/identity.rate-limit.test.js +33 -22
- package/dist/pam/identity/__tests__/identity.rate-limit.test.js.map +1 -1
- package/dist/pam/identity/__tests__/identity.service.test.js +217 -298
- package/dist/pam/identity/__tests__/identity.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/login-enforcement.service.test.js +97 -32
- package/dist/pam/identity/__tests__/login-enforcement.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/phone-login.service.test.js +163 -134
- package/dist/pam/identity/__tests__/phone-login.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/rg-guard.test.d.ts +2 -0
- package/dist/pam/identity/__tests__/rg-guard.test.d.ts.map +1 -0
- package/dist/pam/identity/__tests__/rg-guard.test.js +21 -0
- package/dist/pam/identity/__tests__/rg-guard.test.js.map +1 -0
- package/dist/pam/player-management/__tests__/kyc-status-writer.test.js +73 -26
- package/dist/pam/player-management/__tests__/kyc-status-writer.test.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.router.test.js +63 -46
- package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.service.test.js +207 -111
- package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
- package/dist/pam/profile/__tests__/profile.service.test.js +89 -41
- package/dist/pam/profile/__tests__/profile.service.test.js.map +1 -1
- package/dist/pam/shared/__tests__/shared-mappers.test.d.ts +2 -0
- package/dist/pam/shared/__tests__/shared-mappers.test.d.ts.map +1 -0
- package/dist/pam/shared/__tests__/shared-mappers.test.js +55 -0
- package/dist/pam/shared/__tests__/shared-mappers.test.js.map +1 -0
- package/dist/pam/tag/__tests__/get-active-tag-keys.test.js +72 -30
- package/dist/pam/tag/__tests__/get-active-tag-keys.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +308 -235
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag-rule.service.test.js +136 -140
- package/dist/pam/tag/__tests__/tag-rule.service.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.router.test.js +76 -25
- package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.service.test.js +224 -247
- package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
- package/dist/server/auth/__tests__/admin-guard.test.js +107 -117
- package/dist/server/auth/__tests__/admin-guard.test.js.map +1 -1
- package/dist/server/auth/__tests__/permission-levels.test.d.ts +2 -0
- package/dist/server/auth/__tests__/permission-levels.test.d.ts.map +1 -0
- package/dist/server/auth/__tests__/permission-levels.test.js +89 -0
- package/dist/server/auth/__tests__/permission-levels.test.js.map +1 -0
- package/dist/server/auth/__tests__/session-resolver.test.js +45 -13
- package/dist/server/auth/__tests__/session-resolver.test.js.map +1 -1
- package/dist/server/db/__tests__/query-helpers.test.js +72 -1
- package/dist/server/db/__tests__/query-helpers.test.js.map +1 -1
- package/dist/server/db/outbox/__tests__/relay.test.js +74 -81
- package/dist/server/db/outbox/__tests__/relay.test.js.map +1 -1
- package/dist/server/kernel/__tests__/bullmq-job-queue.test.js +204 -154
- package/dist/server/kernel/__tests__/bullmq-job-queue.test.js.map +1 -1
- package/dist/server/kernel/__tests__/cache.test.js +26 -13
- package/dist/server/kernel/__tests__/cache.test.js.map +1 -1
- package/dist/server/kernel/__tests__/domain-error.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/domain-error.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/domain-error.test.js +35 -0
- package/dist/server/kernel/__tests__/domain-error.test.js.map +1 -0
- package/dist/server/kernel/__tests__/error-reporter.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/error-reporter.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/error-reporter.test.js +52 -0
- package/dist/server/kernel/__tests__/error-reporter.test.js.map +1 -0
- package/dist/server/kernel/__tests__/event-bus.test.js +60 -20
- package/dist/server/kernel/__tests__/event-bus.test.js.map +1 -1
- package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/orpc-error-map.test.js +54 -0
- package/dist/server/kernel/__tests__/orpc-error-map.test.js.map +1 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.js +112 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.js.map +1 -0
- package/dist/server/kernel/__tests__/rate-limiter.test.js +22 -13
- package/dist/server/kernel/__tests__/rate-limiter.test.js.map +1 -1
- package/dist/server/kernel/__tests__/realtime-transport.test.d.ts.map +1 -0
- package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js.map +1 -1
- package/dist/server/kernel/__tests__/redis-cache.test.js +34 -34
- package/dist/server/kernel/__tests__/redis-cache.test.js.map +1 -1
- package/dist/server/kernel/__tests__/redis-rate-limiter.test.js +49 -44
- package/dist/server/kernel/__tests__/redis-rate-limiter.test.js.map +1 -1
- package/dist/server/kernel/__tests__/redis-streams-broker.test.js +141 -262
- package/dist/server/kernel/__tests__/redis-streams-broker.test.js.map +1 -1
- package/dist/server/kernel/__tests__/request-context.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/request-context.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/request-context.test.js +64 -0
- package/dist/server/kernel/__tests__/request-context.test.js.map +1 -0
- package/dist/server/kernel/__tests__/router-utils.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/router-utils.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/router-utils.test.js +80 -0
- package/dist/server/kernel/__tests__/router-utils.test.js.map +1 -0
- package/dist/server/kernel/bullmq-job-queue.d.ts.map +1 -1
- package/dist/server/kernel/bullmq-job-queue.js +8 -2
- package/dist/server/kernel/bullmq-job-queue.js.map +1 -1
- package/dist/server/runtime/__tests__/create-app.test.js +26 -18
- package/dist/server/runtime/__tests__/create-app.test.js.map +1 -1
- package/dist/testing/index.d.ts +2 -6
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +2 -10
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock.d.ts +28 -16
- package/dist/testing/mock.d.ts.map +1 -1
- package/dist/testing/mock.js +58 -37
- package/dist/testing/mock.js.map +1 -1
- package/dist/testing/real-infra.d.ts +47 -0
- package/dist/testing/real-infra.d.ts.map +1 -0
- package/dist/testing/real-infra.js +122 -0
- package/dist/testing/real-infra.js.map +1 -0
- package/dist/wallet/__tests__/admin-reporting.test.js +201 -90
- package/dist/wallet/__tests__/admin-reporting.test.js.map +1 -1
- package/dist/wallet/__tests__/rail-for.test.d.ts +2 -0
- package/dist/wallet/__tests__/rail-for.test.d.ts.map +1 -0
- package/dist/wallet/__tests__/rail-for.test.js +22 -0
- package/dist/wallet/__tests__/rail-for.test.js.map +1 -0
- package/dist/wallet/__tests__/wallet-auto-rule.router.test.js +82 -63
- package/dist/wallet/__tests__/wallet-auto-rule.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +414 -437
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-commands.service.test.js +116 -155
- package/dist/wallet/__tests__/wallet-commands.service.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-kyc-gate.test.js +63 -42
- package/dist/wallet/__tests__/wallet-kyc-gate.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-webhook.router.test.js +129 -51
- package/dist/wallet/__tests__/wallet-webhook.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.rate-limit.test.js +74 -21
- package/dist/wallet/__tests__/wallet.rate-limit.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.router.test.js +56 -36
- package/dist/wallet/__tests__/wallet.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.service.test.js +623 -887
- package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
- package/package.json +1 -2
- package/dist/casino/lobby/__tests__/lobby.service.test.d.ts +0 -2
- package/dist/casino/lobby/__tests__/lobby.service.test.d.ts.map +0 -1
- package/dist/casino/lobby/__tests__/lobby.service.test.js +0 -11
- package/dist/casino/lobby/__tests__/lobby.service.test.js.map +0 -1
- package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts +0 -2
- package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts.map +0 -1
- package/dist/contracts/schemas/__tests__/identity-schemas.test.js +0 -43
- package/dist/contracts/schemas/__tests__/identity-schemas.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/cache.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/cache.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/cache.test.js +0 -89
- package/dist/testing/fakes/__tests__/cache.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/job-queue.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/job-queue.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/job-queue.test.js +0 -135
- package/dist/testing/fakes/__tests__/job-queue.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/rate-limiter.test.js +0 -83
- package/dist/testing/fakes/__tests__/rate-limiter.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.js +0 -19
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/realtime-transport.test.d.ts.map +0 -1
- package/dist/testing/fakes/cache.d.ts +0 -14
- package/dist/testing/fakes/cache.d.ts.map +0 -1
- package/dist/testing/fakes/cache.js +0 -56
- package/dist/testing/fakes/cache.js.map +0 -1
- package/dist/testing/fakes/event-bus.d.ts +0 -17
- package/dist/testing/fakes/event-bus.d.ts.map +0 -1
- package/dist/testing/fakes/event-bus.js +0 -30
- package/dist/testing/fakes/event-bus.js.map +0 -1
- package/dist/testing/fakes/job-queue.d.ts +0 -40
- package/dist/testing/fakes/job-queue.d.ts.map +0 -1
- package/dist/testing/fakes/job-queue.js +0 -200
- package/dist/testing/fakes/job-queue.js.map +0 -1
- package/dist/testing/fakes/rate-limiter.d.ts +0 -11
- package/dist/testing/fakes/rate-limiter.d.ts.map +0 -1
- package/dist/testing/fakes/rate-limiter.js +0 -39
- package/dist/testing/fakes/rate-limiter.js.map +0 -1
- package/dist/testing/fakes/realtime-authorizer.d.ts +0 -10
- package/dist/testing/fakes/realtime-authorizer.d.ts.map +0 -1
- package/dist/testing/fakes/realtime-authorizer.js +0 -10
- package/dist/testing/fakes/realtime-authorizer.js.map +0 -1
- package/dist/testing/fakes/realtime-transport.d.ts +0 -2
- package/dist/testing/fakes/realtime-transport.d.ts.map +0 -1
- package/dist/testing/fakes/realtime-transport.js +0 -2
- package/dist/testing/fakes/realtime-transport.js.map +0 -1
- /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.d.ts +0 -0
- /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js +0 -0
|
@@ -1,28 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { eq, sql } from 'drizzle-orm';
|
|
4
|
+
import { createTestDb, InProcessRealtimeTransport } 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 { NO_CLIENT_META, makeEventBus } from '../../../testing/mock.js';
|
|
1
8
|
import { CHAT_ROOM_CATEGORIES } from '../contract/index.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
9
|
+
import { MAX_PRIVATE_ROOMS_PER_PLAYER } from '../contract/constants.js';
|
|
10
|
+
import { migrate } from '../migrate.js';
|
|
11
|
+
import { chatRoom, chatMessage, chatUserBlock, chatRoomMember, chatRoomBan, } from '../schema/index.js';
|
|
12
|
+
import { ChatService, chatChannel, ChatRoomNotFoundError, ChatMessageNotFoundError, ChatMessageOwnershipError, ChatMessageBlockedError, ChatSelfBlockError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, ChatRoomLimitReachedError, ChatRoomLastModeratorError, ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, } from '../service/chat.service.js';
|
|
13
|
+
let db;
|
|
14
|
+
function makeService() {
|
|
15
|
+
const transport = new InProcessRealtimeTransport();
|
|
16
|
+
const events = makeEventBus();
|
|
17
|
+
return { svc: new ChatService(db.drizzle, events, transport), events, transport };
|
|
18
|
+
}
|
|
19
|
+
async function seedUser(name = 'Player') {
|
|
20
|
+
const [row] = await db.drizzle.db
|
|
21
|
+
.insert(user)
|
|
22
|
+
.values({ name, email: `${randomUUID()}@test.dev`, emailVerified: true })
|
|
23
|
+
.returning();
|
|
24
|
+
return row;
|
|
25
|
+
}
|
|
26
|
+
async function seedRoom(overrides = {}) {
|
|
27
|
+
const [row] = await db.drizzle.db
|
|
28
|
+
.insert(chatRoom)
|
|
29
|
+
.values({
|
|
30
|
+
name: 'Jackpot Wheel',
|
|
31
|
+
slug: `room-${randomUUID()}`,
|
|
32
|
+
category: 'games-sports',
|
|
33
|
+
...overrides,
|
|
34
|
+
})
|
|
35
|
+
.returning();
|
|
36
|
+
return row;
|
|
37
|
+
}
|
|
38
|
+
async function seedMessage(overrides = {}) {
|
|
39
|
+
const [row] = await db.drizzle.db
|
|
40
|
+
.insert(chatMessage)
|
|
41
|
+
.values({
|
|
42
|
+
roomId: null,
|
|
43
|
+
userId: randomUUID(),
|
|
44
|
+
username: 'alice',
|
|
45
|
+
content: 'hi',
|
|
46
|
+
...overrides,
|
|
47
|
+
})
|
|
48
|
+
.returning();
|
|
49
|
+
return row;
|
|
50
|
+
}
|
|
51
|
+
async function waitFor(predicate, timeoutMs = 2000) {
|
|
52
|
+
const deadline = Date.now() + timeoutMs;
|
|
53
|
+
while (!predicate()) {
|
|
54
|
+
if (Date.now() > deadline) {
|
|
55
|
+
throw new Error('condition not met within the timeout');
|
|
56
|
+
}
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
db = await createTestDb([migrate, migrateIdentity]);
|
|
62
|
+
});
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
await db.drop();
|
|
65
|
+
});
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
await db.drizzle.db.execute(sql `TRUNCATE ${chatMessage}, ${chatRoomMember}, ${chatRoomBan}, ${chatUserBlock}, ${chatRoom}, ${user} RESTART IDENTITY CASCADE`);
|
|
24
68
|
});
|
|
25
|
-
describe('Chat
|
|
69
|
+
describe('Chat contract surface', () => {
|
|
26
70
|
it('exposes the complete persisted category set', () => {
|
|
27
71
|
expect(CHAT_ROOM_CATEGORIES).toEqual([
|
|
28
72
|
'games-sports',
|
|
@@ -46,32 +90,30 @@ describe('ChatService realtime wiring', () => {
|
|
|
46
90
|
expect(chatChannel(null)).toBe('chat:global');
|
|
47
91
|
expect(chatChannel('r1')).toBe('chat:room:r1');
|
|
48
92
|
});
|
|
49
|
-
it('delivers messages published on a room channel
|
|
50
|
-
const transport =
|
|
51
|
-
const service = new ChatService(mockDb({}), mock({ emit: () => undefined }), transport);
|
|
93
|
+
it('delivers messages published on a room channel, then stops on unsubscribe', () => {
|
|
94
|
+
const { svc, transport } = makeService();
|
|
52
95
|
const got = [];
|
|
53
|
-
const unsub =
|
|
96
|
+
const unsub = svc.subscribeMessages('r1', (m) => got.push(m));
|
|
54
97
|
transport.publish(chatChannel('r1'), sample);
|
|
55
98
|
expect(got).toEqual([sample]);
|
|
56
|
-
transport.publish(chatChannel(null), { ...sample, roomId: null });
|
|
99
|
+
transport.publish(chatChannel(null), { ...sample, roomId: null });
|
|
57
100
|
expect(got).toHaveLength(1);
|
|
58
101
|
unsub();
|
|
59
102
|
transport.publish(chatChannel('r1'), sample);
|
|
60
103
|
expect(got).toHaveLength(1);
|
|
61
104
|
});
|
|
62
105
|
it('counts each authenticated user once across multiple stream connections', async () => {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
await expect(service.getOnlineCount('r1')).resolves.toEqual({ count: 1 });
|
|
106
|
+
const { svc } = makeService();
|
|
107
|
+
const first = svc.subscribeMessages('r1', () => undefined, 'u1', 'tab-1');
|
|
108
|
+
const second = svc.subscribeMessages('r1', () => undefined, 'u1', 'tab-2');
|
|
109
|
+
await expect(svc.getOnlineCount('r1')).resolves.toEqual({ count: 1 });
|
|
68
110
|
first();
|
|
69
|
-
await expect(
|
|
111
|
+
await expect(svc.getOnlineCount('r1')).resolves.toEqual({ count: 1 });
|
|
70
112
|
second();
|
|
71
|
-
await expect(
|
|
113
|
+
await expect(svc.getOnlineCount('r1')).resolves.toEqual({ count: 0 });
|
|
72
114
|
});
|
|
73
115
|
});
|
|
74
|
-
describe('ChatService.subscribeMessages per-viewer block filtering (
|
|
116
|
+
describe('ChatService.subscribeMessages per-viewer block filtering (real PG)', () => {
|
|
75
117
|
const sample = {
|
|
76
118
|
id: 'm1',
|
|
77
119
|
roomId: null,
|
|
@@ -81,400 +123,518 @@ describe('ChatService.subscribeMessages per-viewer block filtering (AC11)', () =
|
|
|
81
123
|
isDeleted: false,
|
|
82
124
|
createdAt: '2026-05-29T00:00:00.000Z',
|
|
83
125
|
};
|
|
84
|
-
// Mocks only the `blockedIdsFor` query: select({ blockedId }).from().where().
|
|
85
|
-
function blockListDb(blockedIds) {
|
|
86
|
-
return mockDb({
|
|
87
|
-
select: () => ({
|
|
88
|
-
from: () => ({ where: async () => blockedIds.map((id) => ({ blockedId: id })) }),
|
|
89
|
-
}),
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
// Drain the microtask queue so the async block-set load resolves and flushes.
|
|
93
|
-
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
94
126
|
it('hides messages from blocked senders for the viewer, delivers the rest', async () => {
|
|
95
|
-
const transport =
|
|
96
|
-
const
|
|
127
|
+
const { svc, transport } = makeService();
|
|
128
|
+
const viewerId = randomUUID();
|
|
129
|
+
const blockedId = randomUUID();
|
|
130
|
+
await svc.blockUser(viewerId, blockedId);
|
|
97
131
|
const got = [];
|
|
98
|
-
|
|
99
|
-
await flush();
|
|
100
|
-
transport.publish(chatChannel(null), { ...sample, userId: 'blocked-user' });
|
|
132
|
+
svc.subscribeMessages(null, (m) => got.push(m), viewerId);
|
|
101
133
|
transport.publish(chatChannel(null), { ...sample, userId: 'other-user' });
|
|
134
|
+
await waitFor(() => got.length === 1);
|
|
135
|
+
transport.publish(chatChannel(null), { ...sample, userId: blockedId });
|
|
102
136
|
expect(got.map((m) => m.userId)).toEqual(['other-user']);
|
|
103
137
|
});
|
|
104
138
|
it('buffers messages that arrive before the block set loads, then filters on flush', async () => {
|
|
105
|
-
const transport =
|
|
106
|
-
const
|
|
139
|
+
const { svc, transport } = makeService();
|
|
140
|
+
const viewerId = randomUUID();
|
|
141
|
+
const blockedId = randomUUID();
|
|
142
|
+
await svc.blockUser(viewerId, blockedId);
|
|
107
143
|
const got = [];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
transport.publish(chatChannel(null), { ...sample, userId: 'blocked-user' });
|
|
144
|
+
svc.subscribeMessages(null, (m) => got.push(m), viewerId);
|
|
145
|
+
transport.publish(chatChannel(null), { ...sample, userId: blockedId });
|
|
111
146
|
transport.publish(chatChannel(null), { ...sample, userId: 'other-user' });
|
|
112
147
|
expect(got).toHaveLength(0);
|
|
113
|
-
await
|
|
148
|
+
await waitFor(() => got.length === 1);
|
|
114
149
|
expect(got.map((m) => m.userId)).toEqual(['other-user']);
|
|
115
150
|
});
|
|
116
|
-
it('
|
|
117
|
-
const transport =
|
|
118
|
-
const failingDb = mockDb({
|
|
119
|
-
select: () => ({
|
|
120
|
-
from: () => ({
|
|
121
|
-
where: async () => {
|
|
122
|
-
throw new Error('db down');
|
|
123
|
-
},
|
|
124
|
-
}),
|
|
125
|
-
}),
|
|
126
|
-
});
|
|
127
|
-
const service = new ChatService(failingDb, mock({ emit: () => undefined }), transport);
|
|
151
|
+
it('delivers everything to an anonymous viewer', async () => {
|
|
152
|
+
const { svc, transport } = makeService();
|
|
128
153
|
const got = [];
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
transport.publish(chatChannel(null), { ...sample, userId: 'anyone' });
|
|
154
|
+
svc.subscribeMessages(null, (m) => got.push(m));
|
|
155
|
+
transport.publish(chatChannel(null), sample);
|
|
132
156
|
expect(got).toHaveLength(1);
|
|
133
157
|
});
|
|
134
158
|
});
|
|
135
|
-
describe('ChatService.sendGlobalMessage
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
where: () => ({ limit: async () => (userName ? [{ name: userName }] : []) }),
|
|
141
|
-
}),
|
|
142
|
-
}),
|
|
143
|
-
insert: () => ({
|
|
144
|
-
values: (v) => ({
|
|
145
|
-
returning: async () => [
|
|
146
|
-
{
|
|
147
|
-
id: 'm1',
|
|
148
|
-
roomId: null,
|
|
149
|
-
userId: v.userId,
|
|
150
|
-
username: v.username,
|
|
151
|
-
content: v.content,
|
|
152
|
-
isDeleted: false,
|
|
153
|
-
createdAt: new Date('2026-06-01T00:00:00.000Z'),
|
|
154
|
-
},
|
|
155
|
-
],
|
|
156
|
-
}),
|
|
157
|
-
}),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
it('stores the display name from the verified user, ignoring the header-derived fallback', async () => {
|
|
161
|
-
const transport = new InProcessRealtimeTransport();
|
|
162
|
-
const service = new ChatService(mockDb(makeDb('Platform Admin')), mock({ emit: () => undefined }), transport);
|
|
163
|
-
const msg = await service.sendGlobalMessage('u1', 'spoofed-header-name', 'hi');
|
|
159
|
+
describe('ChatService.sendGlobalMessage (real PG)', () => {
|
|
160
|
+
it('stores the display name from the verified user, ignoring the header fallback', async () => {
|
|
161
|
+
const { svc, events } = makeService();
|
|
162
|
+
const account = await seedUser('Platform Admin');
|
|
163
|
+
const msg = await svc.sendGlobalMessage(account.id, 'spoofed-header-name', 'hi');
|
|
164
164
|
expect(msg.username).toBe('Platform Admin');
|
|
165
|
+
const [stored] = await db.drizzle.db.select().from(chatMessage);
|
|
166
|
+
expect(stored).toMatchObject({ roomId: null, username: 'Platform Admin' });
|
|
167
|
+
expect(events.emit).toHaveBeenCalledWith('chat.message.sent', {
|
|
168
|
+
messageId: msg.id,
|
|
169
|
+
roomId: null,
|
|
170
|
+
userId: account.id,
|
|
171
|
+
});
|
|
165
172
|
});
|
|
166
|
-
it('falls back to the passed name, then anonymous, when
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
expect((await
|
|
173
|
+
it('falls back to the passed name, then anonymous, when no user row exists', async () => {
|
|
174
|
+
const { svc } = makeService();
|
|
175
|
+
const userId = randomUUID();
|
|
176
|
+
expect((await svc.sendGlobalMessage(userId, 'fallback', 'hi')).username).toBe('fallback');
|
|
177
|
+
expect((await svc.sendGlobalMessage(userId, '', 'hi')).username).toBe('anonymous');
|
|
170
178
|
});
|
|
171
|
-
it('blocks profane messages before they are stored or published
|
|
172
|
-
const transport =
|
|
179
|
+
it('blocks profane messages before they are stored or published', async () => {
|
|
180
|
+
const { svc, transport } = makeService();
|
|
173
181
|
const delivered = [];
|
|
174
182
|
transport.subscribe('chat:global', (m) => delivered.push(m));
|
|
175
|
-
|
|
176
|
-
await expect(service.sendGlobalMessage('u1', 'Alice', 'this is shit')).rejects.toThrow(ChatMessageBlockedError);
|
|
183
|
+
await expect(svc.sendGlobalMessage(randomUUID(), 'Alice', 'this is shit')).rejects.toThrow(ChatMessageBlockedError);
|
|
177
184
|
expect(delivered).toHaveLength(0);
|
|
185
|
+
expect(await db.drizzle.db.select().from(chatMessage)).toHaveLength(0);
|
|
178
186
|
});
|
|
179
|
-
it('persists URL-sanitized content
|
|
180
|
-
const
|
|
181
|
-
const msg = await
|
|
187
|
+
it('persists URL-sanitized content', async () => {
|
|
188
|
+
const { svc } = makeService();
|
|
189
|
+
const msg = await svc.sendGlobalMessage(randomUUID(), 'Alice', 'click javascript:alert(1)');
|
|
182
190
|
expect(msg.content).toBe('click javascript alert(1)');
|
|
191
|
+
const [stored] = await db.drizzle.db.select().from(chatMessage);
|
|
192
|
+
expect(stored?.content).toBe('click javascript alert(1)');
|
|
193
|
+
});
|
|
194
|
+
it('publishes the stored message on the global channel', async () => {
|
|
195
|
+
const { svc, transport } = makeService();
|
|
196
|
+
const delivered = [];
|
|
197
|
+
transport.subscribe('chat:global', (m) => delivered.push(m));
|
|
198
|
+
const msg = await svc.sendGlobalMessage(randomUUID(), 'Alice', 'hello');
|
|
199
|
+
expect(delivered.map((m) => m.id)).toEqual([msg.id]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
describe('ChatService message reads (real PG)', () => {
|
|
203
|
+
it('returns global messages newest-first, excluding soft-deleted ones', async () => {
|
|
204
|
+
const { svc } = makeService();
|
|
205
|
+
const older = await seedMessage({ createdAt: new Date('2026-01-01T00:00:00.000Z') });
|
|
206
|
+
const newer = await seedMessage({ createdAt: new Date('2026-02-01T00:00:00.000Z') });
|
|
207
|
+
await seedMessage({ isDeleted: true });
|
|
208
|
+
const messages = await svc.getGlobalMessages();
|
|
209
|
+
expect(messages.map((m) => m.id)).toEqual([newer.id, older.id]);
|
|
210
|
+
});
|
|
211
|
+
it('filters out senders the viewer has blocked', async () => {
|
|
212
|
+
const { svc } = makeService();
|
|
213
|
+
const viewerId = randomUUID();
|
|
214
|
+
const blockedId = randomUUID();
|
|
215
|
+
await svc.blockUser(viewerId, blockedId);
|
|
216
|
+
await seedMessage({ userId: blockedId });
|
|
217
|
+
const visible = await seedMessage();
|
|
218
|
+
const messages = await svc.getGlobalMessages(50, viewerId);
|
|
219
|
+
expect(messages.map((m) => m.id)).toEqual([visible.id]);
|
|
220
|
+
});
|
|
221
|
+
it('scopes room messages to the room and honours the before cursor', async () => {
|
|
222
|
+
const { svc } = makeService();
|
|
223
|
+
const room = await seedRoom();
|
|
224
|
+
const old = await seedMessage({
|
|
225
|
+
roomId: room.id,
|
|
226
|
+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
227
|
+
});
|
|
228
|
+
await seedMessage({ roomId: room.id, createdAt: new Date('2026-03-01T00:00:00.000Z') });
|
|
229
|
+
await seedMessage();
|
|
230
|
+
const messages = await svc.getRoomMessages({
|
|
231
|
+
roomId: room.id,
|
|
232
|
+
before: '2026-02-01T00:00:00.000Z',
|
|
233
|
+
});
|
|
234
|
+
expect(messages.map((m) => m.id)).toEqual([old.id]);
|
|
183
235
|
});
|
|
184
236
|
});
|
|
185
|
-
describe('ChatService.
|
|
237
|
+
describe('ChatService.sendRoomMessage (real PG)', () => {
|
|
238
|
+
it('stores and publishes a message in a public room', async () => {
|
|
239
|
+
const { svc, transport } = makeService();
|
|
240
|
+
const room = await seedRoom();
|
|
241
|
+
const account = await seedUser('Alice');
|
|
242
|
+
const delivered = [];
|
|
243
|
+
transport.subscribe(chatChannel(room.id), (m) => delivered.push(m));
|
|
244
|
+
const msg = await svc.sendRoomMessage({
|
|
245
|
+
userId: account.id,
|
|
246
|
+
username: 'ignored',
|
|
247
|
+
roomId: room.id,
|
|
248
|
+
content: 'hello room',
|
|
249
|
+
});
|
|
250
|
+
expect(msg).toMatchObject({ roomId: room.id, username: 'Alice' });
|
|
251
|
+
expect(delivered.map((m) => m.id)).toEqual([msg.id]);
|
|
252
|
+
});
|
|
253
|
+
it('refuses a private room the sender has not joined', async () => {
|
|
254
|
+
const { svc } = makeService();
|
|
255
|
+
const room = await seedRoom({ isPublic: false, category: 'private-channels' });
|
|
256
|
+
await expect(svc.sendRoomMessage({
|
|
257
|
+
userId: randomUUID(),
|
|
258
|
+
username: 'a',
|
|
259
|
+
roomId: room.id,
|
|
260
|
+
content: 'hi',
|
|
261
|
+
})).rejects.toBeInstanceOf(ChatRoomNotMemberError);
|
|
262
|
+
expect(await db.drizzle.db.select().from(chatMessage)).toHaveLength(0);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
describe('ChatService.deleteMessage (real PG)', () => {
|
|
266
|
+
it('soft-deletes the senders own message', async () => {
|
|
267
|
+
const { svc } = makeService();
|
|
268
|
+
const message = await seedMessage();
|
|
269
|
+
await expect(svc.deleteMessage(message.id, message.userId)).resolves.toEqual({ success: true });
|
|
270
|
+
const [stored] = await db.drizzle.db
|
|
271
|
+
.select()
|
|
272
|
+
.from(chatMessage)
|
|
273
|
+
.where(eq(chatMessage.id, message.id));
|
|
274
|
+
expect(stored?.isDeleted).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
it('throws ChatMessageNotFoundError for an unknown id', async () => {
|
|
277
|
+
const { svc } = makeService();
|
|
278
|
+
await expect(svc.deleteMessage(randomUUID(), randomUUID())).rejects.toBeInstanceOf(ChatMessageNotFoundError);
|
|
279
|
+
});
|
|
280
|
+
it('refuses to delete another players message', async () => {
|
|
281
|
+
const { svc } = makeService();
|
|
282
|
+
const message = await seedMessage();
|
|
283
|
+
await expect(svc.deleteMessage(message.id, randomUUID())).rejects.toBeInstanceOf(ChatMessageOwnershipError);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
describe('ChatService block list (real PG)', () => {
|
|
186
287
|
it('rejects blocking yourself', async () => {
|
|
187
|
-
const
|
|
188
|
-
|
|
288
|
+
const { svc } = makeService();
|
|
289
|
+
const userId = randomUUID();
|
|
290
|
+
await expect(svc.blockUser(userId, userId)).rejects.toThrow(ChatSelfBlockError);
|
|
291
|
+
});
|
|
292
|
+
it('emits only on the first block of a pair', async () => {
|
|
293
|
+
const { svc, events } = makeService();
|
|
294
|
+
const blockerId = randomUUID();
|
|
295
|
+
const blockedId = randomUUID();
|
|
296
|
+
await svc.blockUser(blockerId, blockedId, NO_CLIENT_META);
|
|
297
|
+
await svc.blockUser(blockerId, blockedId, NO_CLIENT_META);
|
|
298
|
+
expect(await db.drizzle.db.select().from(chatUserBlock)).toHaveLength(1);
|
|
299
|
+
expect(events.emit.mock.calls.filter(([topic]) => topic === 'chat.user.blocked')).toHaveLength(1);
|
|
300
|
+
});
|
|
301
|
+
it('emits on unblock only when a row was actually removed', async () => {
|
|
302
|
+
const { svc, events } = makeService();
|
|
303
|
+
const blockerId = randomUUID();
|
|
304
|
+
const blockedId = randomUUID();
|
|
305
|
+
await svc.blockUser(blockerId, blockedId);
|
|
306
|
+
await svc.unblockUser(blockerId, blockedId, NO_CLIENT_META);
|
|
307
|
+
await svc.unblockUser(blockerId, blockedId, NO_CLIENT_META);
|
|
308
|
+
expect(await db.drizzle.db.select().from(chatUserBlock)).toHaveLength(0);
|
|
309
|
+
expect(events.emit.mock.calls.filter(([topic]) => topic === 'chat.user.unblocked')).toHaveLength(1);
|
|
310
|
+
});
|
|
311
|
+
it('lists the blocked ids newest-first', async () => {
|
|
312
|
+
const { svc } = makeService();
|
|
313
|
+
const blockerId = randomUUID();
|
|
314
|
+
const first = randomUUID();
|
|
315
|
+
const second = randomUUID();
|
|
316
|
+
await db.drizzle.db.insert(chatUserBlock).values([
|
|
317
|
+
{ blockerId, blockedId: first, createdAt: new Date('2026-01-01T00:00:00.000Z') },
|
|
318
|
+
{ blockerId, blockedId: second, createdAt: new Date('2026-02-01T00:00:00.000Z') },
|
|
319
|
+
]);
|
|
320
|
+
const rows = await svc.listBlockedUsers(blockerId);
|
|
321
|
+
expect(rows.map((r) => r.blockedId)).toEqual([second, first]);
|
|
189
322
|
});
|
|
190
323
|
});
|
|
191
|
-
|
|
192
|
-
return new ChatService(drizzle, mock({ emit: () => undefined }), new InProcessRealtimeTransport());
|
|
193
|
-
}
|
|
194
|
-
// Public room row shape shared by admin room tests.
|
|
195
|
-
const publicRoomRow = {
|
|
196
|
-
id: 'r1',
|
|
197
|
-
name: 'Jackpot Wheel',
|
|
198
|
-
slug: 'jackpot-wheel',
|
|
199
|
-
category: 'games-sports',
|
|
200
|
-
isPublic: true,
|
|
201
|
-
joinCode: null,
|
|
202
|
-
creatorId: null,
|
|
203
|
-
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
204
|
-
};
|
|
205
|
-
describe('ChatService.createRoom', () => {
|
|
324
|
+
describe('ChatService admin rooms (real PG)', () => {
|
|
206
325
|
it('creates a room and returns the serialized row', async () => {
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
const result = await makeAdminService(drizzle).createRoom({
|
|
326
|
+
const { svc, events } = makeService();
|
|
327
|
+
const room = await svc.createRoom({
|
|
210
328
|
name: 'Jackpot Wheel',
|
|
211
329
|
slug: 'jackpot-wheel',
|
|
212
330
|
category: 'games-sports',
|
|
213
331
|
...NO_CLIENT_META,
|
|
214
332
|
});
|
|
215
|
-
expect(
|
|
216
|
-
expect(
|
|
217
|
-
expect(
|
|
218
|
-
});
|
|
219
|
-
it('throws ChatRoomSlugConflictError when slug
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
await expect(
|
|
223
|
-
name: '
|
|
224
|
-
slug: '
|
|
225
|
-
category: '
|
|
333
|
+
expect(room).toMatchObject({ slug: 'jackpot-wheel', isPublic: true });
|
|
334
|
+
expect(typeof room.createdAt).toBe('string');
|
|
335
|
+
expect(events.emit).toHaveBeenCalledWith('chat.room.created', expect.objectContaining({ roomId: room.id, slug: 'jackpot-wheel' }));
|
|
336
|
+
});
|
|
337
|
+
it('throws ChatRoomSlugConflictError when the slug is taken', async () => {
|
|
338
|
+
const { svc } = makeService();
|
|
339
|
+
await seedRoom({ slug: 'taken' });
|
|
340
|
+
await expect(svc.createRoom({
|
|
341
|
+
name: 'Other',
|
|
342
|
+
slug: 'taken',
|
|
343
|
+
category: 'regions',
|
|
226
344
|
...NO_CLIENT_META,
|
|
227
|
-
})).rejects.
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
await
|
|
234
|
-
|
|
235
|
-
|
|
345
|
+
})).rejects.toBeInstanceOf(ChatRoomSlugConflictError);
|
|
346
|
+
});
|
|
347
|
+
it('pages public rooms for the requested sort', async () => {
|
|
348
|
+
const { svc } = makeService();
|
|
349
|
+
await seedRoom({ name: 'Alpha', slug: 'alpha' });
|
|
350
|
+
await seedRoom({ name: 'Beta', slug: 'beta' });
|
|
351
|
+
await seedRoom({ name: 'Private', slug: 'priv', isPublic: false });
|
|
352
|
+
const result = await svc.listAdminRooms({
|
|
353
|
+
page: 1,
|
|
354
|
+
limit: 1,
|
|
236
355
|
sortBy: 'name',
|
|
237
356
|
sortOrder: 'asc',
|
|
238
|
-
})).resolves.toEqual({
|
|
239
|
-
items: [{ ...publicRoomRow, createdAt: '2026-01-01T00:00:00.000Z' }],
|
|
240
|
-
total: 1,
|
|
241
|
-
page: 2,
|
|
242
|
-
limit: 10,
|
|
243
357
|
});
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
it('updates a room and emits
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const drizzle = makeDrizzle({ select: [[publicRoomRow]], returning: [[updatedRoom]] });
|
|
255
|
-
const events = makeEvents();
|
|
256
|
-
const service = new ChatService(drizzle, mock(events), new InProcessRealtimeTransport());
|
|
257
|
-
await expect(service.updateRoom({
|
|
258
|
-
id: 'r1',
|
|
259
|
-
name: 'Mega Wheel',
|
|
260
|
-
slug: 'mega-wheel',
|
|
358
|
+
expect(result.total).toBe(2);
|
|
359
|
+
expect(result.items.map((r) => r.name)).toEqual(['Alpha']);
|
|
360
|
+
});
|
|
361
|
+
it('updates a room and emits a before/after event', async () => {
|
|
362
|
+
const { svc, events } = makeService();
|
|
363
|
+
const room = await seedRoom({ name: 'Old', slug: 'old', category: 'games-sports' });
|
|
364
|
+
const actorId = randomUUID();
|
|
365
|
+
const updated = await svc.updateRoom({
|
|
366
|
+
id: room.id,
|
|
367
|
+
name: 'New',
|
|
261
368
|
category: 'regions',
|
|
262
|
-
actorId
|
|
369
|
+
actorId,
|
|
263
370
|
...NO_CLIENT_META,
|
|
264
|
-
})).resolves.toMatchObject({ name: 'Mega Wheel', slug: 'mega-wheel', category: 'regions' });
|
|
265
|
-
expect(events.emit).toHaveBeenCalledWith('chat.room.updated', {
|
|
266
|
-
roomId: 'r1',
|
|
267
|
-
actorId: 'admin-1',
|
|
268
|
-
before: { name: 'Jackpot Wheel', slug: 'jackpot-wheel', category: 'games-sports' },
|
|
269
|
-
after: { name: 'Mega Wheel', slug: 'mega-wheel', category: 'regions' },
|
|
270
|
-
ip: null,
|
|
271
|
-
userAgent: null,
|
|
272
371
|
});
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
372
|
+
expect(updated).toMatchObject({ name: 'New', category: 'regions', slug: 'old' });
|
|
373
|
+
expect(events.emit).toHaveBeenCalledWith('chat.room.updated', expect.objectContaining({
|
|
374
|
+
roomId: room.id,
|
|
375
|
+
actorId,
|
|
376
|
+
before: expect.objectContaining({ name: 'Old', category: 'games-sports' }),
|
|
377
|
+
after: expect.objectContaining({ name: 'New', category: 'regions' }),
|
|
378
|
+
}));
|
|
379
|
+
});
|
|
380
|
+
it('rejects an update that collides with another rooms slug', async () => {
|
|
381
|
+
const { svc } = makeService();
|
|
382
|
+
await seedRoom({ slug: 'taken' });
|
|
383
|
+
const room = await seedRoom({ slug: 'mine' });
|
|
384
|
+
await expect(svc.updateRoom({ id: room.id, slug: 'taken', ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomSlugConflictError);
|
|
385
|
+
});
|
|
386
|
+
it('soft-deletes the room while keeping its messages', async () => {
|
|
387
|
+
const { svc } = makeService();
|
|
388
|
+
const room = await seedRoom();
|
|
389
|
+
await seedMessage({ roomId: room.id });
|
|
390
|
+
await expect(svc.deleteRoom(room.id, randomUUID(), NO_CLIENT_META)).resolves.toEqual({
|
|
391
|
+
success: true,
|
|
280
392
|
});
|
|
281
|
-
const
|
|
282
|
-
expect(
|
|
393
|
+
const [stored] = await db.drizzle.db.select().from(chatRoom).where(eq(chatRoom.id, room.id));
|
|
394
|
+
expect(stored?.deletedAt).toBeInstanceOf(Date);
|
|
395
|
+
expect(await db.drizzle.db.select().from(chatMessage)).toHaveLength(1);
|
|
283
396
|
});
|
|
284
|
-
it('throws ChatRoomNotFoundError when
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
await
|
|
397
|
+
it('throws ChatRoomNotFoundError when deleting twice', async () => {
|
|
398
|
+
const { svc } = makeService();
|
|
399
|
+
const room = await seedRoom();
|
|
400
|
+
await svc.deleteRoom(room.id);
|
|
401
|
+
await expect(svc.deleteRoom(room.id)).rejects.toBeInstanceOf(ChatRoomNotFoundError);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
describe('ChatService.createPrivateRoom (real PG)', () => {
|
|
405
|
+
it('creates the room, joins the creator as moderator, and issues a join code', async () => {
|
|
406
|
+
const { svc, events } = makeService();
|
|
407
|
+
const userId = randomUUID();
|
|
408
|
+
const room = await svc.createPrivateRoom({ userId, name: 'My Room', ...NO_CLIENT_META });
|
|
409
|
+
expect(room).toMatchObject({ isPublic: false, category: 'private-channels' });
|
|
410
|
+
expect(room.joinCode).toMatch(/^[A-Z2-9]{6}$/);
|
|
411
|
+
expect(room.slug.startsWith('private-')).toBe(true);
|
|
412
|
+
expect(room.slug).not.toContain(room.joinCode?.toLowerCase());
|
|
413
|
+
const [member] = await db.drizzle.db
|
|
414
|
+
.select()
|
|
415
|
+
.from(chatRoomMember)
|
|
416
|
+
.where(eq(chatRoomMember.roomId, room.id));
|
|
417
|
+
expect(member).toMatchObject({ userId, role: 'moderator' });
|
|
418
|
+
expect(events.emit).toHaveBeenCalledWith('chat.private_room.created', expect.objectContaining({ roomId: room.id, creatorId: userId }));
|
|
419
|
+
});
|
|
420
|
+
it('rejects one room past the per-player cap', async () => {
|
|
421
|
+
const { svc } = makeService();
|
|
422
|
+
const userId = randomUUID();
|
|
423
|
+
for (let i = 0; i < MAX_PRIVATE_ROOMS_PER_PLAYER; i++) {
|
|
424
|
+
await svc.createPrivateRoom({ userId, name: `Room ${i}`, ...NO_CLIENT_META });
|
|
425
|
+
}
|
|
426
|
+
await expect(svc.createPrivateRoom({ userId, name: 'One too many', ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomLimitReachedError);
|
|
427
|
+
});
|
|
428
|
+
it('frees a slot once a room is soft-deleted', async () => {
|
|
429
|
+
const { svc } = makeService();
|
|
430
|
+
const userId = randomUUID();
|
|
431
|
+
const rooms = [];
|
|
432
|
+
for (let i = 0; i < MAX_PRIVATE_ROOMS_PER_PLAYER; i++) {
|
|
433
|
+
rooms.push(await svc.createPrivateRoom({ userId, name: `Room ${i}`, ...NO_CLIENT_META }));
|
|
434
|
+
}
|
|
435
|
+
await svc.deleteRoom(rooms[0].id);
|
|
436
|
+
await expect(svc.createPrivateRoom({ userId, name: 'Replacement', ...NO_CLIENT_META })).resolves.toMatchObject({ name: 'Replacement' });
|
|
437
|
+
});
|
|
438
|
+
it('serializes concurrent creates so the cap cannot be overshot', async () => {
|
|
439
|
+
const { svc } = makeService();
|
|
440
|
+
const userId = randomUUID();
|
|
441
|
+
for (let i = 0; i < MAX_PRIVATE_ROOMS_PER_PLAYER - 1; i++) {
|
|
442
|
+
await svc.createPrivateRoom({ userId, name: `Room ${i}`, ...NO_CLIENT_META });
|
|
443
|
+
}
|
|
444
|
+
const settled = await Promise.allSettled([
|
|
445
|
+
svc.createPrivateRoom({ userId, name: 'A', ...NO_CLIENT_META }),
|
|
446
|
+
svc.createPrivateRoom({ userId, name: 'B', ...NO_CLIENT_META }),
|
|
447
|
+
]);
|
|
448
|
+
expect(settled.filter((s) => s.status === 'fulfilled')).toHaveLength(1);
|
|
288
449
|
});
|
|
289
450
|
});
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
joinCode: 'ABC123',
|
|
298
|
-
creatorId: 'u1',
|
|
299
|
-
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
300
|
-
};
|
|
301
|
-
describe('ChatService.createPrivateRoom', () => {
|
|
302
|
-
it('creates a private room, joins creator as moderator, and returns serialized row', async () => {
|
|
303
|
-
// (1) count private rooms -> 0, in tx: (2) insert chatRoom -> returning[0], (3) insert member -> no-op
|
|
304
|
-
const drizzle = makeDrizzle({ select: [[{ total: 0 }], []], returning: [[privateRoomRow]] });
|
|
305
|
-
const result = await makeAdminService(drizzle).createPrivateRoom({
|
|
306
|
-
userId: 'u1',
|
|
307
|
-
name: 'Squad Chat',
|
|
451
|
+
describe('ChatService.joinRoom (real PG)', () => {
|
|
452
|
+
it('joins by code and records the membership once', async () => {
|
|
453
|
+
const { svc, events } = makeService();
|
|
454
|
+
const creatorId = randomUUID();
|
|
455
|
+
const room = await svc.createPrivateRoom({
|
|
456
|
+
userId: creatorId,
|
|
457
|
+
name: 'Room',
|
|
308
458
|
...NO_CLIENT_META,
|
|
309
459
|
});
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
460
|
+
const joinerId = randomUUID();
|
|
461
|
+
await svc.joinRoom({ userId: joinerId, joinCode: room.joinCode, ...NO_CLIENT_META });
|
|
462
|
+
await svc.joinRoom({ userId: joinerId, joinCode: room.joinCode, ...NO_CLIENT_META });
|
|
463
|
+
const members = await db.drizzle.db
|
|
464
|
+
.select()
|
|
465
|
+
.from(chatRoomMember)
|
|
466
|
+
.where(eq(chatRoomMember.roomId, room.id));
|
|
467
|
+
expect(members).toHaveLength(2);
|
|
468
|
+
expect(events.emit.mock.calls.filter(([topic]) => topic === 'chat.room.member.joined')).toHaveLength(1);
|
|
469
|
+
});
|
|
470
|
+
it('throws ChatRoomJoinCodeNotFoundError for an unknown code', async () => {
|
|
471
|
+
const { svc } = makeService();
|
|
472
|
+
await expect(svc.joinRoom({ userId: randomUUID(), joinCode: 'ZZZZZZ', ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomJoinCodeNotFoundError);
|
|
473
|
+
});
|
|
474
|
+
it('throws ChatRoomBannedError for a banned user', async () => {
|
|
475
|
+
const { svc } = makeService();
|
|
476
|
+
const creatorId = randomUUID();
|
|
477
|
+
const room = await svc.createPrivateRoom({
|
|
478
|
+
userId: creatorId,
|
|
479
|
+
name: 'Room',
|
|
322
480
|
...NO_CLIENT_META,
|
|
323
481
|
});
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
});
|
|
329
|
-
expect(room.slug).not.toContain(room.joinCode.toLowerCase());
|
|
482
|
+
const bannedId = randomUUID();
|
|
483
|
+
await db.drizzle.db
|
|
484
|
+
.insert(chatRoomBan)
|
|
485
|
+
.values({ roomId: room.id, userId: bannedId, bannedBy: creatorId });
|
|
486
|
+
await expect(svc.joinRoom({ userId: bannedId, joinCode: room.joinCode, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomBannedError);
|
|
330
487
|
});
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
it('takes a creator-scoped advisory lock before counting and inserting', async () => {
|
|
340
|
-
const drizzle = makeDrizzle({ select: [[{ total: 0 }]], returning: [[privateRoomRow]] });
|
|
341
|
-
const executeSpy = readPrivate(drizzle.db, 'execute');
|
|
342
|
-
await makeAdminService(drizzle).createPrivateRoom({
|
|
343
|
-
userId: 'u1',
|
|
344
|
-
name: 'Squad Chat',
|
|
488
|
+
});
|
|
489
|
+
describe('ChatService.leaveRoom (real PG)', () => {
|
|
490
|
+
it('refuses to let the sole moderator leave', async () => {
|
|
491
|
+
const { svc } = makeService();
|
|
492
|
+
const creatorId = randomUUID();
|
|
493
|
+
const room = await svc.createPrivateRoom({
|
|
494
|
+
userId: creatorId,
|
|
495
|
+
name: 'Room',
|
|
345
496
|
...NO_CLIENT_META,
|
|
346
497
|
});
|
|
347
|
-
expect(
|
|
498
|
+
await expect(svc.leaveRoom({ userId: creatorId, roomId: room.id, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomLastModeratorError);
|
|
348
499
|
});
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const result = await makeAdminService(drizzle).joinRoom({
|
|
355
|
-
userId: 'u2',
|
|
356
|
-
joinCode: 'ABC123',
|
|
500
|
+
it('removes a plain member and emits left', async () => {
|
|
501
|
+
const { svc, events } = makeService();
|
|
502
|
+
const room = await svc.createPrivateRoom({
|
|
503
|
+
userId: randomUUID(),
|
|
504
|
+
name: 'Room',
|
|
357
505
|
...NO_CLIENT_META,
|
|
358
506
|
});
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
// (1) find room id by joinCode, (2) re-read room, (3) check ban -> ban record found
|
|
369
|
-
const drizzle = makeDrizzle({
|
|
370
|
-
select: [[{ id: 'r2' }], [privateRoomRow], [{ id: 'ban1' }]],
|
|
371
|
-
});
|
|
372
|
-
await expect(makeAdminService(drizzle).joinRoom({ userId: 'u2', joinCode: 'ABC123', ...NO_CLIENT_META })).rejects.toThrow(ChatRoomBannedError);
|
|
507
|
+
const memberId = randomUUID();
|
|
508
|
+
await svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META });
|
|
509
|
+
await svc.leaveRoom({ userId: memberId, roomId: room.id, ...NO_CLIENT_META });
|
|
510
|
+
const members = await db.drizzle.db
|
|
511
|
+
.select()
|
|
512
|
+
.from(chatRoomMember)
|
|
513
|
+
.where(eq(chatRoomMember.roomId, room.id));
|
|
514
|
+
expect(members.map((m) => m.userId)).not.toContain(memberId);
|
|
515
|
+
expect(events.emit).toHaveBeenCalledWith('chat.room.member.left', expect.objectContaining({ roomId: room.id, userId: memberId }));
|
|
373
516
|
});
|
|
374
517
|
});
|
|
375
|
-
describe('ChatService
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
moderatorId: 'u1',
|
|
383
|
-
roomId: 'r2',
|
|
384
|
-
userId: 'u2',
|
|
518
|
+
describe('ChatService moderation (real PG)', () => {
|
|
519
|
+
async function roomWithMember() {
|
|
520
|
+
const { svc, events } = makeService();
|
|
521
|
+
const moderatorId = randomUUID();
|
|
522
|
+
const room = await svc.createPrivateRoom({
|
|
523
|
+
userId: moderatorId,
|
|
524
|
+
name: 'Room',
|
|
385
525
|
...NO_CLIENT_META,
|
|
386
526
|
});
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
});
|
|
393
|
-
await
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
527
|
+
const memberId = randomUUID();
|
|
528
|
+
await svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META });
|
|
529
|
+
return { svc, events, room, moderatorId, memberId };
|
|
530
|
+
}
|
|
531
|
+
it('kicks a member, who can then rejoin with the code', async () => {
|
|
532
|
+
const { svc, room, moderatorId, memberId } = await roomWithMember();
|
|
533
|
+
await svc.kickMember({ moderatorId, roomId: room.id, userId: memberId, ...NO_CLIENT_META });
|
|
534
|
+
const afterKick = await db.drizzle.db
|
|
535
|
+
.select()
|
|
536
|
+
.from(chatRoomMember)
|
|
537
|
+
.where(eq(chatRoomMember.roomId, room.id));
|
|
538
|
+
expect(afterKick.map((m) => m.userId)).not.toContain(memberId);
|
|
539
|
+
await expect(svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META })).resolves.toMatchObject({ id: room.id });
|
|
540
|
+
});
|
|
541
|
+
it('bans a member, removing membership and blocking a rejoin', async () => {
|
|
542
|
+
const { svc, room, moderatorId, memberId } = await roomWithMember();
|
|
543
|
+
await svc.banMember({ moderatorId, roomId: room.id, userId: memberId, ...NO_CLIENT_META });
|
|
544
|
+
expect(await db.drizzle.db.select().from(chatRoomBan).where(eq(chatRoomBan.roomId, room.id))).toHaveLength(1);
|
|
545
|
+
await expect(svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomBannedError);
|
|
546
|
+
});
|
|
547
|
+
it('is idempotent on a repeated ban', async () => {
|
|
548
|
+
const { svc, room, moderatorId, memberId } = await roomWithMember();
|
|
549
|
+
await svc.banMember({ moderatorId, roomId: room.id, userId: memberId, ...NO_CLIENT_META });
|
|
550
|
+
await svc.banMember({ moderatorId, roomId: room.id, userId: memberId, ...NO_CLIENT_META });
|
|
551
|
+
expect(await db.drizzle.db.select().from(chatRoomBan).where(eq(chatRoomBan.roomId, room.id))).toHaveLength(1);
|
|
552
|
+
});
|
|
553
|
+
it('refuses moderation by a plain member', async () => {
|
|
554
|
+
const { svc, room, memberId } = await roomWithMember();
|
|
555
|
+
const otherId = randomUUID();
|
|
556
|
+
await svc.joinRoom({ userId: otherId, joinCode: room.joinCode, ...NO_CLIENT_META });
|
|
557
|
+
await expect(svc.kickMember({
|
|
558
|
+
moderatorId: memberId,
|
|
559
|
+
roomId: room.id,
|
|
560
|
+
userId: otherId,
|
|
561
|
+
...NO_CLIENT_META,
|
|
562
|
+
})).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
|
|
563
|
+
});
|
|
564
|
+
it('refuses moderation by a non-member', async () => {
|
|
565
|
+
const { svc, room, memberId } = await roomWithMember();
|
|
566
|
+
await expect(svc.kickMember({
|
|
567
|
+
moderatorId: randomUUID(),
|
|
568
|
+
roomId: room.id,
|
|
569
|
+
userId: memberId,
|
|
397
570
|
...NO_CLIENT_META,
|
|
398
|
-
})).rejects.
|
|
399
|
-
});
|
|
400
|
-
it('
|
|
401
|
-
const
|
|
402
|
-
await expect(
|
|
403
|
-
moderatorId
|
|
404
|
-
roomId:
|
|
405
|
-
userId:
|
|
571
|
+
})).rejects.toBeInstanceOf(ChatRoomNotMemberError);
|
|
572
|
+
});
|
|
573
|
+
it('refuses self-moderation', async () => {
|
|
574
|
+
const { svc, room, moderatorId } = await roomWithMember();
|
|
575
|
+
await expect(svc.kickMember({
|
|
576
|
+
moderatorId,
|
|
577
|
+
roomId: room.id,
|
|
578
|
+
userId: moderatorId,
|
|
406
579
|
...NO_CLIENT_META,
|
|
407
|
-
})).rejects.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
moderatorId: 'u1',
|
|
413
|
-
roomId: 'r2',
|
|
414
|
-
userId: 'u1',
|
|
580
|
+
})).rejects.toBeInstanceOf(ChatRoomSelfModerationError);
|
|
581
|
+
await expect(svc.banMember({
|
|
582
|
+
moderatorId,
|
|
583
|
+
roomId: room.id,
|
|
584
|
+
userId: moderatorId,
|
|
415
585
|
...NO_CLIENT_META,
|
|
416
|
-
})).rejects.
|
|
586
|
+
})).rejects.toBeInstanceOf(ChatRoomSelfModerationError);
|
|
417
587
|
});
|
|
418
588
|
});
|
|
419
|
-
describe('ChatService.
|
|
420
|
-
it('
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
589
|
+
describe('ChatService.verifyRoomAccess and listings (real PG)', () => {
|
|
590
|
+
it('returns a public room without checking membership', async () => {
|
|
591
|
+
const { svc } = makeService();
|
|
592
|
+
const room = await seedRoom();
|
|
593
|
+
await expect(svc.verifyRoomAccess(room.id)).resolves.toMatchObject({ id: room.id });
|
|
594
|
+
});
|
|
595
|
+
it('throws ChatRoomNotFoundError for an unknown or deleted room', async () => {
|
|
596
|
+
const { svc } = makeService();
|
|
597
|
+
const room = await seedRoom();
|
|
598
|
+
await svc.deleteRoom(room.id);
|
|
599
|
+
await expect(svc.verifyRoomAccess(randomUUID())).rejects.toBeInstanceOf(ChatRoomNotFoundError);
|
|
600
|
+
await expect(svc.verifyRoomAccess(room.id)).rejects.toBeInstanceOf(ChatRoomNotFoundError);
|
|
601
|
+
});
|
|
602
|
+
it('requires membership for a private room', async () => {
|
|
603
|
+
const { svc } = makeService();
|
|
604
|
+
const room = await seedRoom({ isPublic: false, category: 'private-channels' });
|
|
605
|
+
await expect(svc.verifyRoomAccess(room.id)).rejects.toBeInstanceOf(ChatRoomNotMemberError);
|
|
606
|
+
await expect(svc.verifyRoomAccess(room.id, randomUUID())).rejects.toBeInstanceOf(ChatRoomNotMemberError);
|
|
607
|
+
});
|
|
608
|
+
it('allows a member into a private room', async () => {
|
|
609
|
+
const { svc } = makeService();
|
|
610
|
+
const userId = randomUUID();
|
|
611
|
+
const room = await svc.createPrivateRoom({ userId, name: 'Room', ...NO_CLIENT_META });
|
|
612
|
+
await expect(svc.verifyRoomAccess(room.id, userId)).resolves.toMatchObject({ id: room.id });
|
|
613
|
+
});
|
|
614
|
+
it('lists public rooms plus the private rooms the viewer belongs to', async () => {
|
|
615
|
+
const { svc } = makeService();
|
|
616
|
+
const publicRoom = await seedRoom({ slug: 'public-room' });
|
|
617
|
+
await seedRoom({ slug: 'other-private', isPublic: false, category: 'private-channels' });
|
|
618
|
+
const userId = randomUUID();
|
|
619
|
+
const mine = await svc.createPrivateRoom({ userId, name: 'Mine', ...NO_CLIENT_META });
|
|
620
|
+
const anonymous = await svc.listRooms();
|
|
621
|
+
const viewer = await svc.listRooms(userId);
|
|
622
|
+
expect(anonymous.map((r) => r.id)).toEqual([publicRoom.id]);
|
|
623
|
+
expect(viewer.map((r) => r.id).sort()).toEqual([publicRoom.id, mine.id].sort());
|
|
624
|
+
});
|
|
625
|
+
it('lists room members oldest-first', async () => {
|
|
626
|
+
const { svc } = makeService();
|
|
627
|
+
const moderatorId = randomUUID();
|
|
628
|
+
const room = await svc.createPrivateRoom({
|
|
629
|
+
userId: moderatorId,
|
|
630
|
+
name: 'Room',
|
|
429
631
|
...NO_CLIENT_META,
|
|
430
632
|
});
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
moderatorId: 'u1',
|
|
437
|
-
roomId: 'r2',
|
|
438
|
-
userId: 'u2',
|
|
439
|
-
...NO_CLIENT_META,
|
|
440
|
-
})).rejects.toThrow(ChatRoomNotModeratorError);
|
|
441
|
-
});
|
|
442
|
-
it('throws ChatRoomSelfModerationError when trying to ban yourself', async () => {
|
|
443
|
-
const drizzle = makeDrizzle({ select: [] });
|
|
444
|
-
await expect(makeAdminService(drizzle).banMember({
|
|
445
|
-
moderatorId: 'u1',
|
|
446
|
-
roomId: 'r2',
|
|
447
|
-
userId: 'u1',
|
|
448
|
-
...NO_CLIENT_META,
|
|
449
|
-
})).rejects.toThrow(ChatRoomSelfModerationError);
|
|
450
|
-
});
|
|
451
|
-
});
|
|
452
|
-
describe('ChatService.verifyRoomAccess', () => {
|
|
453
|
-
it('returns the room for public rooms without checking membership', async () => {
|
|
454
|
-
// (1) room lookup -> public room
|
|
455
|
-
const drizzle = makeDrizzle({ select: [[publicRoomRow]] });
|
|
456
|
-
const room = await makeAdminService(drizzle).verifyRoomAccess('r1');
|
|
457
|
-
expect(room.id).toBe('r1');
|
|
458
|
-
});
|
|
459
|
-
it('throws ChatRoomNotFoundError when room does not exist', async () => {
|
|
460
|
-
const drizzle = makeDrizzle({ select: [[]] });
|
|
461
|
-
await expect(makeAdminService(drizzle).verifyRoomAccess('r1')).rejects.toThrow(ChatRoomNotFoundError);
|
|
462
|
-
});
|
|
463
|
-
it('throws ChatRoomNotMemberError for private rooms when no viewerId supplied', async () => {
|
|
464
|
-
// (1) room lookup -> private room
|
|
465
|
-
const drizzle = makeDrizzle({ select: [[privateRoomRow]] });
|
|
466
|
-
await expect(makeAdminService(drizzle).verifyRoomAccess('r2')).rejects.toThrow(ChatRoomNotMemberError);
|
|
467
|
-
});
|
|
468
|
-
it('throws ChatRoomNotMemberError for private rooms when viewer is not a member', async () => {
|
|
469
|
-
// (1) room lookup -> private room, (2) member check -> empty
|
|
470
|
-
const drizzle = makeDrizzle({ select: [[privateRoomRow], []] });
|
|
471
|
-
await expect(makeAdminService(drizzle).verifyRoomAccess('r2', 'outsider')).rejects.toThrow(ChatRoomNotMemberError);
|
|
472
|
-
});
|
|
473
|
-
it('allows access for private rooms when viewer is a member', async () => {
|
|
474
|
-
// (1) room lookup -> private room, (2) member check -> member record
|
|
475
|
-
const drizzle = makeDrizzle({ select: [[privateRoomRow], [{ id: 'mem1' }]] });
|
|
476
|
-
const room = await makeAdminService(drizzle).verifyRoomAccess('r2', 'u1');
|
|
477
|
-
expect(room.id).toBe('r2');
|
|
633
|
+
const memberId = randomUUID();
|
|
634
|
+
await svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META });
|
|
635
|
+
const members = await svc.listRoomMembers({ roomId: room.id, viewerId: moderatorId });
|
|
636
|
+
expect(members.map((m) => m.userId)).toEqual([moderatorId, memberId]);
|
|
637
|
+
expect(members[0]).toMatchObject({ role: 'moderator' });
|
|
478
638
|
});
|
|
479
639
|
});
|
|
480
640
|
//# sourceMappingURL=chat.service.test.js.map
|