@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.
Files changed (259) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/__tests__/backoffice.router.test.js +67 -51
  3. package/dist/admin-console/__tests__/backoffice.router.test.js.map +1 -1
  4. package/dist/admin-console/__tests__/backoffice.service.test.js +1 -13
  5. package/dist/admin-console/__tests__/backoffice.service.test.js.map +1 -1
  6. package/dist/analytics/__tests__/analytics.contract.test.js +12 -0
  7. package/dist/analytics/__tests__/analytics.contract.test.js.map +1 -1
  8. package/dist/analytics/__tests__/financial-analytics.service.test.js +3 -3
  9. package/dist/analytics/__tests__/financial-analytics.service.test.js.map +1 -1
  10. package/dist/analytics/service/financial-analytics.service.d.ts.map +1 -1
  11. package/dist/analytics/service/financial-analytics.service.js +14 -17
  12. package/dist/analytics/service/financial-analytics.service.js.map +1 -1
  13. package/dist/analytics/service/funnel-analytics.service.d.ts.map +1 -1
  14. package/dist/analytics/service/funnel-analytics.service.js +8 -11
  15. package/dist/analytics/service/funnel-analytics.service.js.map +1 -1
  16. package/dist/audit/__tests__/audit.service.test.js +128 -251
  17. package/dist/audit/__tests__/audit.service.test.js.map +1 -1
  18. package/dist/casino/gaming/__tests__/gaming.service.test.js +83 -89
  19. package/dist/casino/gaming/__tests__/gaming.service.test.js.map +1 -1
  20. package/dist/casino/lobby/__tests__/lobby.cache.test.js +42 -33
  21. package/dist/casino/lobby/__tests__/lobby.cache.test.js.map +1 -1
  22. package/dist/cms/__tests__/cms.reads.test.js +59 -80
  23. package/dist/cms/__tests__/cms.reads.test.js.map +1 -1
  24. package/dist/cms/__tests__/cms.router.test.js +62 -20
  25. package/dist/cms/__tests__/cms.router.test.js.map +1 -1
  26. package/dist/cms/__tests__/cms.service.test.js +116 -14
  27. package/dist/cms/__tests__/cms.service.test.js.map +1 -1
  28. package/dist/common/errors/__tests__/errors.mapper.test.d.ts +2 -0
  29. package/dist/common/errors/__tests__/errors.mapper.test.d.ts.map +1 -0
  30. package/dist/common/errors/__tests__/errors.mapper.test.js +59 -0
  31. package/dist/common/errors/__tests__/errors.mapper.test.js.map +1 -0
  32. package/dist/compliance/__tests__/assert.test.d.ts +2 -0
  33. package/dist/compliance/__tests__/assert.test.d.ts.map +1 -0
  34. package/dist/compliance/__tests__/assert.test.js +34 -0
  35. package/dist/compliance/__tests__/assert.test.js.map +1 -0
  36. package/dist/compliance/__tests__/compliance.service.test.js +149 -84
  37. package/dist/compliance/__tests__/compliance.service.test.js.map +1 -1
  38. package/dist/compliance/__tests__/kyc.service.test.js +247 -155
  39. package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
  40. package/dist/compliance/__tests__/limits.contract.test.d.ts +2 -0
  41. package/dist/compliance/__tests__/limits.contract.test.d.ts.map +1 -0
  42. package/dist/compliance/__tests__/limits.contract.test.js +43 -0
  43. package/dist/compliance/__tests__/limits.contract.test.js.map +1 -0
  44. package/dist/compliance/__tests__/rg-monitoring.service.test.js +256 -101
  45. package/dist/compliance/__tests__/rg-monitoring.service.test.js.map +1 -1
  46. package/dist/compliance/__tests__/rg.contract.test.js +1 -20
  47. package/dist/compliance/__tests__/rg.contract.test.js.map +1 -1
  48. package/dist/compliance/__tests__/rg.router.test.js +113 -65
  49. package/dist/compliance/__tests__/rg.router.test.js.map +1 -1
  50. package/dist/compliance/__tests__/rg.service.test.js +277 -235
  51. package/dist/compliance/__tests__/rg.service.test.js.map +1 -1
  52. package/dist/compliance/service/rg-monitoring.service.d.ts.map +1 -1
  53. package/dist/compliance/service/rg-monitoring.service.js +11 -8
  54. package/dist/compliance/service/rg-monitoring.service.js.map +1 -1
  55. package/dist/contracts/__tests__/compose-contract.test.d.ts +2 -0
  56. package/dist/contracts/__tests__/compose-contract.test.d.ts.map +1 -0
  57. package/dist/contracts/__tests__/compose-contract.test.js +27 -0
  58. package/dist/contracts/__tests__/compose-contract.test.js.map +1 -0
  59. package/dist/contracts/adapters/__tests__/token.test.d.ts +2 -0
  60. package/dist/contracts/adapters/__tests__/token.test.d.ts.map +1 -0
  61. package/dist/contracts/adapters/__tests__/token.test.js +14 -0
  62. package/dist/contracts/adapters/__tests__/token.test.js.map +1 -0
  63. package/dist/contracts/schemas/__tests__/events.test.d.ts +2 -0
  64. package/dist/contracts/schemas/__tests__/events.test.d.ts.map +1 -0
  65. package/dist/contracts/schemas/__tests__/events.test.js +51 -0
  66. package/dist/contracts/schemas/__tests__/events.test.js.map +1 -0
  67. package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts +2 -0
  68. package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts.map +1 -0
  69. package/dist/contracts/schemas/__tests__/igaming-config.test.js +38 -0
  70. package/dist/contracts/schemas/__tests__/igaming-config.test.js.map +1 -0
  71. package/dist/contracts/schemas/reporting.d.ts +7 -2
  72. package/dist/contracts/schemas/reporting.d.ts.map +1 -1
  73. package/dist/contracts/schemas/reporting.js +2 -1
  74. package/dist/contracts/schemas/reporting.js.map +1 -1
  75. package/dist/engagement/chat/__tests__/chat.service.test.js +524 -364
  76. package/dist/engagement/chat/__tests__/chat.service.test.js.map +1 -1
  77. package/dist/engagement/notifications/__tests__/notifications.service.test.js +125 -68
  78. package/dist/engagement/notifications/__tests__/notifications.service.test.js.map +1 -1
  79. package/dist/iam/__tests__/iam.service.test.js +367 -596
  80. package/dist/iam/__tests__/iam.service.test.js.map +1 -1
  81. package/dist/pam/identity/__tests__/admin-user-directory.test.js +144 -68
  82. package/dist/pam/identity/__tests__/admin-user-directory.test.js.map +1 -1
  83. package/dist/pam/identity/__tests__/identity.rate-limit.test.js +33 -22
  84. package/dist/pam/identity/__tests__/identity.rate-limit.test.js.map +1 -1
  85. package/dist/pam/identity/__tests__/identity.service.test.js +217 -298
  86. package/dist/pam/identity/__tests__/identity.service.test.js.map +1 -1
  87. package/dist/pam/identity/__tests__/login-enforcement.service.test.js +97 -32
  88. package/dist/pam/identity/__tests__/login-enforcement.service.test.js.map +1 -1
  89. package/dist/pam/identity/__tests__/phone-login.service.test.js +163 -134
  90. package/dist/pam/identity/__tests__/phone-login.service.test.js.map +1 -1
  91. package/dist/pam/identity/__tests__/rg-guard.test.d.ts +2 -0
  92. package/dist/pam/identity/__tests__/rg-guard.test.d.ts.map +1 -0
  93. package/dist/pam/identity/__tests__/rg-guard.test.js +21 -0
  94. package/dist/pam/identity/__tests__/rg-guard.test.js.map +1 -0
  95. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js +73 -26
  96. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js.map +1 -1
  97. package/dist/pam/player-management/__tests__/player.router.test.js +63 -46
  98. package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
  99. package/dist/pam/player-management/__tests__/player.service.test.js +207 -111
  100. package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
  101. package/dist/pam/profile/__tests__/profile.service.test.js +89 -41
  102. package/dist/pam/profile/__tests__/profile.service.test.js.map +1 -1
  103. package/dist/pam/shared/__tests__/shared-mappers.test.d.ts +2 -0
  104. package/dist/pam/shared/__tests__/shared-mappers.test.d.ts.map +1 -0
  105. package/dist/pam/shared/__tests__/shared-mappers.test.js +55 -0
  106. package/dist/pam/shared/__tests__/shared-mappers.test.js.map +1 -0
  107. package/dist/pam/tag/__tests__/get-active-tag-keys.test.js +72 -30
  108. package/dist/pam/tag/__tests__/get-active-tag-keys.test.js.map +1 -1
  109. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +308 -235
  110. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
  111. package/dist/pam/tag/__tests__/tag-rule.service.test.js +136 -140
  112. package/dist/pam/tag/__tests__/tag-rule.service.test.js.map +1 -1
  113. package/dist/pam/tag/__tests__/tag.router.test.js +76 -25
  114. package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
  115. package/dist/pam/tag/__tests__/tag.service.test.js +224 -247
  116. package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
  117. package/dist/server/auth/__tests__/admin-guard.test.js +107 -117
  118. package/dist/server/auth/__tests__/admin-guard.test.js.map +1 -1
  119. package/dist/server/auth/__tests__/permission-levels.test.d.ts +2 -0
  120. package/dist/server/auth/__tests__/permission-levels.test.d.ts.map +1 -0
  121. package/dist/server/auth/__tests__/permission-levels.test.js +89 -0
  122. package/dist/server/auth/__tests__/permission-levels.test.js.map +1 -0
  123. package/dist/server/auth/__tests__/session-resolver.test.js +45 -13
  124. package/dist/server/auth/__tests__/session-resolver.test.js.map +1 -1
  125. package/dist/server/db/__tests__/query-helpers.test.js +72 -1
  126. package/dist/server/db/__tests__/query-helpers.test.js.map +1 -1
  127. package/dist/server/db/outbox/__tests__/relay.test.js +74 -81
  128. package/dist/server/db/outbox/__tests__/relay.test.js.map +1 -1
  129. package/dist/server/kernel/__tests__/bullmq-job-queue.test.js +204 -154
  130. package/dist/server/kernel/__tests__/bullmq-job-queue.test.js.map +1 -1
  131. package/dist/server/kernel/__tests__/cache.test.js +26 -13
  132. package/dist/server/kernel/__tests__/cache.test.js.map +1 -1
  133. package/dist/server/kernel/__tests__/domain-error.test.d.ts +2 -0
  134. package/dist/server/kernel/__tests__/domain-error.test.d.ts.map +1 -0
  135. package/dist/server/kernel/__tests__/domain-error.test.js +35 -0
  136. package/dist/server/kernel/__tests__/domain-error.test.js.map +1 -0
  137. package/dist/server/kernel/__tests__/error-reporter.test.d.ts +2 -0
  138. package/dist/server/kernel/__tests__/error-reporter.test.d.ts.map +1 -0
  139. package/dist/server/kernel/__tests__/error-reporter.test.js +52 -0
  140. package/dist/server/kernel/__tests__/error-reporter.test.js.map +1 -0
  141. package/dist/server/kernel/__tests__/event-bus.test.js +60 -20
  142. package/dist/server/kernel/__tests__/event-bus.test.js.map +1 -1
  143. package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts +2 -0
  144. package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts.map +1 -0
  145. package/dist/server/kernel/__tests__/orpc-error-map.test.js +54 -0
  146. package/dist/server/kernel/__tests__/orpc-error-map.test.js.map +1 -0
  147. package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts +2 -0
  148. package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts.map +1 -0
  149. package/dist/server/kernel/__tests__/platform-config-loader.test.js +112 -0
  150. package/dist/server/kernel/__tests__/platform-config-loader.test.js.map +1 -0
  151. package/dist/server/kernel/__tests__/rate-limiter.test.js +22 -13
  152. package/dist/server/kernel/__tests__/rate-limiter.test.js.map +1 -1
  153. package/dist/server/kernel/__tests__/realtime-transport.test.d.ts.map +1 -0
  154. package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js.map +1 -1
  155. package/dist/server/kernel/__tests__/redis-cache.test.js +34 -34
  156. package/dist/server/kernel/__tests__/redis-cache.test.js.map +1 -1
  157. package/dist/server/kernel/__tests__/redis-rate-limiter.test.js +49 -44
  158. package/dist/server/kernel/__tests__/redis-rate-limiter.test.js.map +1 -1
  159. package/dist/server/kernel/__tests__/redis-streams-broker.test.js +141 -262
  160. package/dist/server/kernel/__tests__/redis-streams-broker.test.js.map +1 -1
  161. package/dist/server/kernel/__tests__/request-context.test.d.ts +2 -0
  162. package/dist/server/kernel/__tests__/request-context.test.d.ts.map +1 -0
  163. package/dist/server/kernel/__tests__/request-context.test.js +64 -0
  164. package/dist/server/kernel/__tests__/request-context.test.js.map +1 -0
  165. package/dist/server/kernel/__tests__/router-utils.test.d.ts +2 -0
  166. package/dist/server/kernel/__tests__/router-utils.test.d.ts.map +1 -0
  167. package/dist/server/kernel/__tests__/router-utils.test.js +80 -0
  168. package/dist/server/kernel/__tests__/router-utils.test.js.map +1 -0
  169. package/dist/server/kernel/bullmq-job-queue.d.ts.map +1 -1
  170. package/dist/server/kernel/bullmq-job-queue.js +8 -2
  171. package/dist/server/kernel/bullmq-job-queue.js.map +1 -1
  172. package/dist/server/runtime/__tests__/create-app.test.js +26 -18
  173. package/dist/server/runtime/__tests__/create-app.test.js.map +1 -1
  174. package/dist/testing/index.d.ts +2 -6
  175. package/dist/testing/index.d.ts.map +1 -1
  176. package/dist/testing/index.js +2 -10
  177. package/dist/testing/index.js.map +1 -1
  178. package/dist/testing/mock.d.ts +28 -16
  179. package/dist/testing/mock.d.ts.map +1 -1
  180. package/dist/testing/mock.js +58 -37
  181. package/dist/testing/mock.js.map +1 -1
  182. package/dist/testing/real-infra.d.ts +47 -0
  183. package/dist/testing/real-infra.d.ts.map +1 -0
  184. package/dist/testing/real-infra.js +122 -0
  185. package/dist/testing/real-infra.js.map +1 -0
  186. package/dist/wallet/__tests__/admin-reporting.test.js +201 -90
  187. package/dist/wallet/__tests__/admin-reporting.test.js.map +1 -1
  188. package/dist/wallet/__tests__/rail-for.test.d.ts +2 -0
  189. package/dist/wallet/__tests__/rail-for.test.d.ts.map +1 -0
  190. package/dist/wallet/__tests__/rail-for.test.js +22 -0
  191. package/dist/wallet/__tests__/rail-for.test.js.map +1 -0
  192. package/dist/wallet/__tests__/wallet-auto-rule.router.test.js +82 -63
  193. package/dist/wallet/__tests__/wallet-auto-rule.router.test.js.map +1 -1
  194. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +414 -437
  195. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
  196. package/dist/wallet/__tests__/wallet-commands.service.test.js +116 -155
  197. package/dist/wallet/__tests__/wallet-commands.service.test.js.map +1 -1
  198. package/dist/wallet/__tests__/wallet-kyc-gate.test.js +63 -42
  199. package/dist/wallet/__tests__/wallet-kyc-gate.test.js.map +1 -1
  200. package/dist/wallet/__tests__/wallet-webhook.router.test.js +129 -51
  201. package/dist/wallet/__tests__/wallet-webhook.router.test.js.map +1 -1
  202. package/dist/wallet/__tests__/wallet.rate-limit.test.js +74 -21
  203. package/dist/wallet/__tests__/wallet.rate-limit.test.js.map +1 -1
  204. package/dist/wallet/__tests__/wallet.router.test.js +56 -36
  205. package/dist/wallet/__tests__/wallet.router.test.js.map +1 -1
  206. package/dist/wallet/__tests__/wallet.service.test.js +623 -887
  207. package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
  208. package/package.json +1 -2
  209. package/dist/casino/lobby/__tests__/lobby.service.test.d.ts +0 -2
  210. package/dist/casino/lobby/__tests__/lobby.service.test.d.ts.map +0 -1
  211. package/dist/casino/lobby/__tests__/lobby.service.test.js +0 -11
  212. package/dist/casino/lobby/__tests__/lobby.service.test.js.map +0 -1
  213. package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts +0 -2
  214. package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts.map +0 -1
  215. package/dist/contracts/schemas/__tests__/identity-schemas.test.js +0 -43
  216. package/dist/contracts/schemas/__tests__/identity-schemas.test.js.map +0 -1
  217. package/dist/testing/fakes/__tests__/cache.test.d.ts +0 -2
  218. package/dist/testing/fakes/__tests__/cache.test.d.ts.map +0 -1
  219. package/dist/testing/fakes/__tests__/cache.test.js +0 -89
  220. package/dist/testing/fakes/__tests__/cache.test.js.map +0 -1
  221. package/dist/testing/fakes/__tests__/job-queue.test.d.ts +0 -2
  222. package/dist/testing/fakes/__tests__/job-queue.test.d.ts.map +0 -1
  223. package/dist/testing/fakes/__tests__/job-queue.test.js +0 -135
  224. package/dist/testing/fakes/__tests__/job-queue.test.js.map +0 -1
  225. package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts +0 -2
  226. package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts.map +0 -1
  227. package/dist/testing/fakes/__tests__/rate-limiter.test.js +0 -83
  228. package/dist/testing/fakes/__tests__/rate-limiter.test.js.map +0 -1
  229. package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts +0 -2
  230. package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts.map +0 -1
  231. package/dist/testing/fakes/__tests__/realtime-authorizer.test.js +0 -19
  232. package/dist/testing/fakes/__tests__/realtime-authorizer.test.js.map +0 -1
  233. package/dist/testing/fakes/__tests__/realtime-transport.test.d.ts.map +0 -1
  234. package/dist/testing/fakes/cache.d.ts +0 -14
  235. package/dist/testing/fakes/cache.d.ts.map +0 -1
  236. package/dist/testing/fakes/cache.js +0 -56
  237. package/dist/testing/fakes/cache.js.map +0 -1
  238. package/dist/testing/fakes/event-bus.d.ts +0 -17
  239. package/dist/testing/fakes/event-bus.d.ts.map +0 -1
  240. package/dist/testing/fakes/event-bus.js +0 -30
  241. package/dist/testing/fakes/event-bus.js.map +0 -1
  242. package/dist/testing/fakes/job-queue.d.ts +0 -40
  243. package/dist/testing/fakes/job-queue.d.ts.map +0 -1
  244. package/dist/testing/fakes/job-queue.js +0 -200
  245. package/dist/testing/fakes/job-queue.js.map +0 -1
  246. package/dist/testing/fakes/rate-limiter.d.ts +0 -11
  247. package/dist/testing/fakes/rate-limiter.d.ts.map +0 -1
  248. package/dist/testing/fakes/rate-limiter.js +0 -39
  249. package/dist/testing/fakes/rate-limiter.js.map +0 -1
  250. package/dist/testing/fakes/realtime-authorizer.d.ts +0 -10
  251. package/dist/testing/fakes/realtime-authorizer.d.ts.map +0 -1
  252. package/dist/testing/fakes/realtime-authorizer.js +0 -10
  253. package/dist/testing/fakes/realtime-authorizer.js.map +0 -1
  254. package/dist/testing/fakes/realtime-transport.d.ts +0 -2
  255. package/dist/testing/fakes/realtime-transport.d.ts.map +0 -1
  256. package/dist/testing/fakes/realtime-transport.js +0 -2
  257. package/dist/testing/fakes/realtime-transport.js.map +0 -1
  258. /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.d.ts +0 -0
  259. /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 { mock, mockDb, makeDrizzle, makeEvents, readPrivate, NO_CLIENT_META, } from '../../../testing/mock.js';
3
- import { describe, it, expect } from 'vitest';
4
- import { ChatService, chatChannel, ChatRoomNotFoundError, ChatMessageNotFoundError, ChatMessageOwnershipError, ChatMessageBlockedError, ChatSelfBlockError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, ChatRoomLimitReachedError, ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, } from '../service/chat.service.js';
5
- import { InProcessRealtimeTransport } from '@openora/core/testing';
6
- describe('ChatService domain errors', () => {
7
- it('ChatRoomNotFoundError carries the id', () => {
8
- const err = new ChatRoomNotFoundError('room-123');
9
- expect(err).toBeInstanceOf(Error);
10
- expect(err.name).toBe('ChatRoomNotFoundError');
11
- expect(err.message).toContain('room-123');
12
- });
13
- it('ChatMessageNotFoundError carries the id', () => {
14
- const err = new ChatMessageNotFoundError('msg-456');
15
- expect(err).toBeInstanceOf(Error);
16
- expect(err.name).toBe('ChatMessageNotFoundError');
17
- expect(err.message).toContain('msg-456');
18
- });
19
- it('ChatMessageOwnershipError is a typed error', () => {
20
- const err = new ChatMessageOwnershipError();
21
- expect(err).toBeInstanceOf(Error);
22
- expect(err.name).toBe('ChatMessageOwnershipError');
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 room categories', () => {
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 to subscribers, then stops on unsubscribe', () => {
50
- const transport = new InProcessRealtimeTransport();
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 = service.subscribeMessages('r1', (m) => got.push(m));
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 }); // global, not r1
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 transport = new InProcessRealtimeTransport();
64
- const service = new ChatService(mockDb({}), mock({ emit: () => undefined }), transport);
65
- const first = service.subscribeMessages('r1', () => undefined, 'u1', 'tab-1');
66
- const second = service.subscribeMessages('r1', () => undefined, 'u1', 'tab-2');
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(service.getOnlineCount('r1')).resolves.toEqual({ count: 1 });
111
+ await expect(svc.getOnlineCount('r1')).resolves.toEqual({ count: 1 });
70
112
  second();
71
- await expect(service.getOnlineCount('r1')).resolves.toEqual({ count: 0 });
113
+ await expect(svc.getOnlineCount('r1')).resolves.toEqual({ count: 0 });
72
114
  });
73
115
  });
74
- describe('ChatService.subscribeMessages per-viewer block filtering (AC11)', () => {
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 = new InProcessRealtimeTransport();
96
- const service = new ChatService(blockListDb(['blocked-user']), mock({ emit: () => undefined }), transport);
127
+ const { svc, transport } = makeService();
128
+ const viewerId = randomUUID();
129
+ const blockedId = randomUUID();
130
+ await svc.blockUser(viewerId, blockedId);
97
131
  const got = [];
98
- service.subscribeMessages(null, (m) => got.push(m), 'viewer-1');
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 = new InProcessRealtimeTransport();
106
- const service = new ChatService(blockListDb(['blocked-user']), mock({ emit: () => undefined }), transport);
139
+ const { svc, transport } = makeService();
140
+ const viewerId = randomUUID();
141
+ const blockedId = randomUUID();
142
+ await svc.blockUser(viewerId, blockedId);
107
143
  const got = [];
108
- service.subscribeMessages(null, (m) => got.push(m), 'viewer-1');
109
- // Published synchronously, before the async block-set load resolves.
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 flush();
148
+ await waitFor(() => got.length === 1);
114
149
  expect(got.map((m) => m.userId)).toEqual(['other-user']);
115
150
  });
116
- it('fails open (delivers all) when the block-list load rejects', async () => {
117
- const transport = new InProcessRealtimeTransport();
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
- service.subscribeMessages(null, (m) => got.push(m), 'viewer-1');
130
- await flush();
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 username resolution', () => {
136
- function makeDb(userName) {
137
- return {
138
- select: () => ({
139
- from: () => ({
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 the user row is missing', async () => {
167
- const service = new ChatService(mockDb(makeDb(null)), mock({ emit: () => undefined }), new InProcessRealtimeTransport());
168
- expect((await service.sendGlobalMessage('u1', 'fallback', 'hi')).username).toBe('fallback');
169
- expect((await service.sendGlobalMessage('u1', '', 'hi')).username).toBe('anonymous');
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 (AC8)', async () => {
172
- const transport = new InProcessRealtimeTransport();
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
- const service = new ChatService(mockDb(makeDb('Alice')), mock({ emit: () => undefined }), transport);
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 (AC7)', async () => {
180
- const service = new ChatService(mockDb(makeDb('Alice')), mock({ emit: () => undefined }), new InProcessRealtimeTransport());
181
- const msg = await service.sendGlobalMessage('u1', 'Alice', 'click javascript:alert(1)');
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.blockUser', () => {
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 service = new ChatService(mockDb({}), mock({ emit: () => undefined }), new InProcessRealtimeTransport());
188
- await expect(service.blockUser('u1', 'u1')).rejects.toThrow(ChatSelfBlockError);
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
- function makeAdminService(drizzle) {
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
- // (1) slug uniqueness check -> empty, (2) insert -> no-op via select queue.
208
- const drizzle = makeDrizzle({ select: [[]], returning: [[publicRoomRow]] });
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(result.id).toBe('r1');
216
- expect(result.slug).toBe('jackpot-wheel');
217
- expect(result.createdAt).toBe('2026-01-01T00:00:00.000Z');
218
- });
219
- it('throws ChatRoomSlugConflictError when slug already exists', async () => {
220
- // slug uniqueness check -> existing row found.
221
- const drizzle = makeDrizzle({ select: [[{ id: 'existing' }]] });
222
- await expect(makeAdminService(drizzle).createRoom({
223
- name: 'Jackpot Wheel',
224
- slug: 'jackpot-wheel',
225
- category: 'games-sports',
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.toThrow(ChatRoomSlugConflictError);
228
- });
229
- });
230
- describe('ChatService.listAdminRooms', () => {
231
- it('returns a paginated public-room list for the requested sort', async () => {
232
- const drizzle = makeDrizzle({ select: [[publicRoomRow], [{ n: 1 }]] });
233
- await expect(makeAdminService(drizzle).listAdminRooms({
234
- page: 2,
235
- limit: 10,
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
- describe('ChatService.updateRoom', () => {
247
- it('updates a room and emits an audit-ready before/after event', async () => {
248
- const updatedRoom = {
249
- ...publicRoomRow,
250
- name: 'Mega Wheel',
251
- slug: 'mega-wheel',
252
- category: 'regions',
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: 'admin-1',
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
- describe('ChatService.deleteRoom', () => {
276
- it('soft-deletes the room and returns success', async () => {
277
- // Single UPDATE...RETURNING sets deletedAt; findOneOrThrow consumes the result.
278
- const drizzle = makeDrizzle({
279
- returning: [[{ id: 'r1', name: 'n', slug: 's', category: 'games-sports' }]],
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 result = await makeAdminService(drizzle).deleteRoom('r1');
282
- expect(result).toEqual({ success: true });
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 room does not exist', async () => {
285
- // UPDATE matches no rows -> returning empty -> findOneOrThrow throws.
286
- const drizzle = makeDrizzle({ returning: [[]] });
287
- await expect(makeAdminService(drizzle).deleteRoom('nonexistent')).rejects.toThrow(ChatRoomNotFoundError);
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
- // Private room row shape shared by private room tests.
291
- const privateRoomRow = {
292
- id: 'r2',
293
- name: 'Squad Chat',
294
- slug: 'private-ef1f8d41-e0ac-4ad3-bd93-3a55e047eb38',
295
- category: 'private-channels',
296
- isPublic: false,
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
- expect(result.id).toBe('r2');
311
- expect(result.isPublic).toBe(false);
312
- expect(result.joinCode).toBe('ABC123');
313
- expect(result.creatorId).toBe('u1');
314
- expect(result.category).toBe('private-channels');
315
- });
316
- it('generates a separate URL-safe slug instead of exposing the join code', async () => {
317
- const drizzle = makeDrizzle({ select: [[{ total: 0 }], []], returning: [[privateRoomRow]] });
318
- const valuesSpy = readPrivate(drizzle.db, 'values');
319
- await makeAdminService(drizzle).createPrivateRoom({
320
- userId: 'u1',
321
- name: 'Squad Chat',
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 [room] = valuesSpy.mock.calls[0] ?? [];
325
- expect(room).toMatchObject({ joinCode: expect.any(String) });
326
- expect(room).toMatchObject({
327
- slug: expect.stringMatching(/^private-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/),
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
- it('rejects creating a sixteenth active private room', async () => {
332
- const drizzle = makeDrizzle({ select: [[{ total: 15 }]] });
333
- await expect(makeAdminService(drizzle).createPrivateRoom({
334
- userId: 'u1',
335
- name: 'One Too Many',
336
- ...NO_CLIENT_META,
337
- })).rejects.toThrow(ChatRoomLimitReachedError);
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(executeSpy).toHaveBeenCalledTimes(1);
498
+ await expect(svc.leaveRoom({ userId: creatorId, roomId: room.id, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomLastModeratorError);
348
499
  });
349
- });
350
- describe('ChatService.joinRoom', () => {
351
- it('joins a private room via join code and returns the room', async () => {
352
- // (1) find room id by joinCode, (2) re-read room under the membership lock, (3) check ban.
353
- const drizzle = makeDrizzle({ select: [[{ id: 'r2' }], [privateRoomRow], []] });
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
- expect(result.id).toBe('r2');
360
- expect(result.joinCode).toBe('ABC123');
361
- });
362
- it('throws ChatRoomJoinCodeNotFoundError when code does not match any room', async () => {
363
- // (1) find room by joinCode -> empty
364
- const drizzle = makeDrizzle({ select: [[]] });
365
- await expect(makeAdminService(drizzle).joinRoom({ userId: 'u2', joinCode: 'BADCODE', ...NO_CLIENT_META })).rejects.toThrow(ChatRoomJoinCodeNotFoundError);
366
- });
367
- it('throws ChatRoomBannedError when user is banned from the room', async () => {
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.kickMember', () => {
376
- it('kicks a member from a private room and returns success', async () => {
377
- // Access check (room + membership), then moderator lookup and member deletion.
378
- const drizzle = makeDrizzle({
379
- select: [[privateRoomRow], [{ id: 'mem1' }], [{ role: 'moderator' }]],
380
- });
381
- const result = await makeAdminService(drizzle).kickMember({
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
- expect(result).toEqual({ success: true });
388
- });
389
- it('throws ChatRoomNotModeratorError when caller is not a moderator', async () => {
390
- const drizzle = makeDrizzle({
391
- select: [[privateRoomRow], [{ id: 'mem1' }], [{ role: 'member' }]],
392
- });
393
- await expect(makeAdminService(drizzle).kickMember({
394
- moderatorId: 'u1',
395
- roomId: 'r2',
396
- userId: 'u2',
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.toThrow(ChatRoomNotModeratorError);
399
- });
400
- it('throws ChatRoomNotModeratorError when caller is not in the room', async () => {
401
- const drizzle = makeDrizzle({ select: [[privateRoomRow], [{ id: 'mem1' }], []] });
402
- await expect(makeAdminService(drizzle).kickMember({
403
- moderatorId: 'u1',
404
- roomId: 'r2',
405
- userId: 'u2',
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.toThrow(ChatRoomNotModeratorError);
408
- });
409
- it('throws ChatRoomSelfModerationError when trying to kick yourself', async () => {
410
- const drizzle = makeDrizzle({ select: [] });
411
- await expect(makeAdminService(drizzle).kickMember({
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.toThrow(ChatRoomSelfModerationError);
586
+ })).rejects.toBeInstanceOf(ChatRoomSelfModerationError);
417
587
  });
418
588
  });
419
- describe('ChatService.banMember', () => {
420
- it('bans a member from a private room (also removes membership) and returns success', async () => {
421
- // Access check (room + membership), then moderator lookup, ban insertion, and member deletion.
422
- const drizzle = makeDrizzle({
423
- select: [[privateRoomRow], [{ id: 'mem1' }], [{ role: 'moderator' }]],
424
- });
425
- const result = await makeAdminService(drizzle).banMember({
426
- moderatorId: 'u1',
427
- roomId: 'r2',
428
- userId: 'u2',
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
- expect(result).toEqual({ success: true });
432
- });
433
- it('throws ChatRoomNotModeratorError when caller is not a moderator', async () => {
434
- const drizzle = makeDrizzle({ select: [[privateRoomRow], [{ id: 'mem1' }], []] });
435
- await expect(makeAdminService(drizzle).banMember({
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