@openora/core 0.4.1-canary.49 → 0.4.1-canary.50

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 (225) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/contract/index.d.ts +12 -0
  3. package/dist/admin-console/contract/index.d.ts.map +1 -1
  4. package/dist/admin-console/router/index.d.ts +6 -0
  5. package/dist/admin-console/router/index.d.ts.map +1 -1
  6. package/dist/audit/plugin.d.ts.map +1 -1
  7. package/dist/audit/plugin.js +19 -4
  8. package/dist/audit/plugin.js.map +1 -1
  9. package/dist/audit/service/audit.service.d.ts +1 -0
  10. package/dist/audit/service/audit.service.d.ts.map +1 -1
  11. package/dist/audit/service/audit.service.js +11 -6
  12. package/dist/audit/service/audit.service.js.map +1 -1
  13. package/dist/casino/gaming/__tests__/gaming.service.test.js +2 -2
  14. package/dist/casino/gaming/__tests__/gaming.service.test.js.map +1 -1
  15. package/dist/casino/gaming/admin-reporting.d.ts +2 -1
  16. package/dist/casino/gaming/admin-reporting.d.ts.map +1 -1
  17. package/dist/casino/gaming/admin-reporting.js +14 -0
  18. package/dist/casino/gaming/admin-reporting.js.map +1 -1
  19. package/dist/compliance/__tests__/hmac-kyc-webhook-verifier.test.js +3 -0
  20. package/dist/compliance/__tests__/hmac-kyc-webhook-verifier.test.js.map +1 -1
  21. package/dist/compliance/drizzle/migrations/0004_military_silk_fever.sql +1 -1
  22. package/dist/contracts/adapters/admin-game-reporting.d.ts +5 -0
  23. package/dist/contracts/adapters/admin-game-reporting.d.ts.map +1 -1
  24. package/dist/contracts/adapters/admin-game-reporting.js.map +1 -1
  25. package/dist/contracts/adapters/admin-user-directory.d.ts +6 -1
  26. package/dist/contracts/adapters/admin-user-directory.d.ts.map +1 -1
  27. package/dist/contracts/adapters/admin-user-directory.js.map +1 -1
  28. package/dist/contracts/adapters/audit.d.ts +10 -0
  29. package/dist/contracts/adapters/audit.d.ts.map +1 -1
  30. package/dist/contracts/adapters/audit.js.map +1 -1
  31. package/dist/contracts/adapters/cache.d.ts +3 -0
  32. package/dist/contracts/adapters/cache.d.ts.map +1 -1
  33. package/dist/contracts/adapters/cache.js.map +1 -1
  34. package/dist/contracts/adapters/chat-block-writer.d.ts +8 -0
  35. package/dist/contracts/adapters/chat-block-writer.d.ts.map +1 -0
  36. package/dist/contracts/adapters/chat-block-writer.js +3 -0
  37. package/dist/contracts/adapters/chat-block-writer.js.map +1 -0
  38. package/dist/contracts/adapters/chat-room-access.d.ts +6 -0
  39. package/dist/contracts/adapters/chat-room-access.d.ts.map +1 -0
  40. package/dist/contracts/adapters/chat-room-access.js +3 -0
  41. package/dist/contracts/adapters/chat-room-access.js.map +1 -0
  42. package/dist/contracts/adapters/chat-system-writer.d.ts +12 -0
  43. package/dist/contracts/adapters/chat-system-writer.d.ts.map +1 -0
  44. package/dist/contracts/adapters/chat-system-writer.js +3 -0
  45. package/dist/contracts/adapters/chat-system-writer.js.map +1 -0
  46. package/dist/contracts/adapters/index.d.ts +8 -2
  47. package/dist/contracts/adapters/index.d.ts.map +1 -1
  48. package/dist/contracts/adapters/index.js +4 -1
  49. package/dist/contracts/adapters/index.js.map +1 -1
  50. package/dist/contracts/adapters/realtime.d.ts +3 -0
  51. package/dist/contracts/adapters/realtime.d.ts.map +1 -1
  52. package/dist/contracts/adapters/realtime.js +14 -0
  53. package/dist/contracts/adapters/realtime.js.map +1 -1
  54. package/dist/contracts/adapters/wallet-commands.d.ts +2 -0
  55. package/dist/contracts/adapters/wallet-commands.d.ts.map +1 -1
  56. package/dist/contracts/adapters/wallet-commands.js.map +1 -1
  57. package/dist/contracts/schemas/chat-command-metadata.d.ts +47 -0
  58. package/dist/contracts/schemas/chat-command-metadata.d.ts.map +1 -0
  59. package/dist/contracts/schemas/chat-command-metadata.js +42 -0
  60. package/dist/contracts/schemas/chat-command-metadata.js.map +1 -0
  61. package/dist/contracts/schemas/chat-command.d.ts +87 -0
  62. package/dist/contracts/schemas/chat-command.d.ts.map +1 -0
  63. package/dist/contracts/schemas/chat-command.js +26 -0
  64. package/dist/contracts/schemas/chat-command.js.map +1 -0
  65. package/dist/contracts/schemas/events.d.ts +59 -0
  66. package/dist/contracts/schemas/events.d.ts.map +1 -1
  67. package/dist/contracts/schemas/events.js +52 -2
  68. package/dist/contracts/schemas/events.js.map +1 -1
  69. package/dist/contracts/schemas/index.d.ts +2 -0
  70. package/dist/contracts/schemas/index.d.ts.map +1 -1
  71. package/dist/contracts/schemas/index.js +2 -0
  72. package/dist/contracts/schemas/index.js.map +1 -1
  73. package/dist/contracts/schemas/wallet-tx.d.ts +3 -1
  74. package/dist/contracts/schemas/wallet-tx.d.ts.map +1 -1
  75. package/dist/contracts/schemas/wallet-tx.js +2 -0
  76. package/dist/contracts/schemas/wallet-tx.js.map +1 -1
  77. package/dist/engagement/chat/__tests__/chat.service.test.js +138 -10
  78. package/dist/engagement/chat/__tests__/chat.service.test.js.map +1 -1
  79. package/dist/engagement/chat/contract/index.d.ts +285 -0
  80. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  81. package/dist/engagement/chat/contract/index.js +23 -1
  82. package/dist/engagement/chat/contract/index.js.map +1 -1
  83. package/dist/engagement/chat/drizzle/migrations/0002_steep_zodiak.sql +3 -0
  84. package/dist/engagement/chat/drizzle/migrations/0003_stormy_king_bedlam.sql +9 -0
  85. package/dist/engagement/chat/drizzle/migrations/meta/0002_snapshot.json +576 -0
  86. package/dist/engagement/chat/drizzle/migrations/meta/0003_snapshot.json +652 -0
  87. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +14 -0
  88. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  89. package/dist/engagement/chat/plugin.js +15 -3
  90. package/dist/engagement/chat/plugin.js.map +1 -1
  91. package/dist/engagement/chat/router/index.d.ts +242 -0
  92. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  93. package/dist/engagement/chat/router/index.js +14 -2
  94. package/dist/engagement/chat/router/index.js.map +1 -1
  95. package/dist/engagement/chat/schema/index.d.ts +179 -0
  96. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  97. package/dist/engagement/chat/schema/index.js +17 -1
  98. package/dist/engagement/chat/schema/index.js.map +1 -1
  99. package/dist/engagement/chat/service/chat.service.d.ts +176 -8
  100. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  101. package/dist/engagement/chat/service/chat.service.js +129 -13
  102. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  103. package/dist/engagement/chat-commands/contract/index.d.ts +252 -0
  104. package/dist/engagement/chat-commands/contract/index.d.ts.map +1 -0
  105. package/dist/engagement/chat-commands/contract/index.js +138 -0
  106. package/dist/engagement/chat-commands/contract/index.js.map +1 -0
  107. package/dist/engagement/chat-commands/drizzle/migrations/0000_daffy_lady_bullseye.sql +24 -0
  108. package/dist/engagement/chat-commands/drizzle/migrations/meta/0000_snapshot.json +164 -0
  109. package/dist/engagement/chat-commands/drizzle/migrations/meta/_journal.json +13 -0
  110. package/dist/engagement/chat-commands/drizzle.config.d.ts +4 -0
  111. package/dist/engagement/chat-commands/drizzle.config.d.ts.map +1 -0
  112. package/dist/engagement/chat-commands/drizzle.config.js +21 -0
  113. package/dist/engagement/chat-commands/drizzle.config.js.map +1 -0
  114. package/dist/engagement/chat-commands/migrate.d.ts +3 -0
  115. package/dist/engagement/chat-commands/migrate.d.ts.map +1 -0
  116. package/dist/engagement/chat-commands/migrate.js +15 -0
  117. package/dist/engagement/chat-commands/migrate.js.map +1 -0
  118. package/dist/engagement/chat-commands/plugin.d.ts +3 -0
  119. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -0
  120. package/dist/engagement/chat-commands/plugin.js +15 -0
  121. package/dist/engagement/chat-commands/plugin.js.map +1 -0
  122. package/dist/engagement/chat-commands/router/index.d.ts +173 -0
  123. package/dist/engagement/chat-commands/router/index.d.ts.map +1 -0
  124. package/dist/engagement/chat-commands/router/index.js +61 -0
  125. package/dist/engagement/chat-commands/router/index.js.map +1 -0
  126. package/dist/engagement/chat-commands/schema/index.d.ts +337 -0
  127. package/dist/engagement/chat-commands/schema/index.d.ts.map +1 -0
  128. package/dist/engagement/chat-commands/schema/index.js +29 -0
  129. package/dist/engagement/chat-commands/schema/index.js.map +1 -0
  130. package/dist/engagement/chat-commands/seed/index.d.ts +3 -0
  131. package/dist/engagement/chat-commands/seed/index.d.ts.map +1 -0
  132. package/dist/engagement/chat-commands/seed/index.js +54 -0
  133. package/dist/engagement/chat-commands/seed/index.js.map +1 -0
  134. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.d.ts +2 -0
  135. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.d.ts.map +1 -0
  136. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js +888 -0
  137. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js.map +1 -0
  138. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts +90 -0
  139. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts.map +1 -0
  140. package/dist/engagement/chat-commands/service/chat-commands.service.js +641 -0
  141. package/dist/engagement/chat-commands/service/chat-commands.service.js.map +1 -0
  142. package/dist/engagement/contracts.d.ts +1 -0
  143. package/dist/engagement/contracts.d.ts.map +1 -1
  144. package/dist/engagement/contracts.js +1 -0
  145. package/dist/engagement/contracts.js.map +1 -1
  146. package/dist/engagement/index.d.ts +1 -0
  147. package/dist/engagement/index.d.ts.map +1 -1
  148. package/dist/engagement/index.js +1 -0
  149. package/dist/engagement/index.js.map +1 -1
  150. package/dist/engagement/server.d.ts +1 -0
  151. package/dist/engagement/server.d.ts.map +1 -1
  152. package/dist/engagement/server.js +1 -0
  153. package/dist/engagement/server.js.map +1 -1
  154. package/dist/iam/service/iam.service.d.ts +1 -1
  155. package/dist/pam/identity/__tests__/admin-user-directory.test.js +24 -0
  156. package/dist/pam/identity/__tests__/admin-user-directory.test.js.map +1 -1
  157. package/dist/pam/identity/admin-user-directory.d.ts +16 -1
  158. package/dist/pam/identity/admin-user-directory.d.ts.map +1 -1
  159. package/dist/pam/identity/admin-user-directory.js +39 -6
  160. package/dist/pam/identity/admin-user-directory.js.map +1 -1
  161. package/dist/react/hooks/__tests__/use-event-stream.test.d.ts +2 -0
  162. package/dist/react/hooks/__tests__/use-event-stream.test.d.ts.map +1 -0
  163. package/dist/react/hooks/__tests__/use-event-stream.test.js +153 -0
  164. package/dist/react/hooks/__tests__/use-event-stream.test.js.map +1 -0
  165. package/dist/react/hooks/use-event-stream.d.ts +3 -3
  166. package/dist/react/hooks/use-event-stream.d.ts.map +1 -1
  167. package/dist/react/hooks/use-event-stream.js +60 -28
  168. package/dist/react/hooks/use-event-stream.js.map +1 -1
  169. package/dist/server/kernel/__tests__/cache.test.js +1 -0
  170. package/dist/server/kernel/__tests__/cache.test.js.map +1 -1
  171. package/dist/server/kernel/__tests__/realtime-transport.test.js +11 -0
  172. package/dist/server/kernel/__tests__/realtime-transport.test.js.map +1 -1
  173. package/dist/server/kernel/realtime-transport.d.ts +2 -0
  174. package/dist/server/kernel/realtime-transport.d.ts.map +1 -1
  175. package/dist/server/kernel/realtime-transport.js +13 -1
  176. package/dist/server/kernel/realtime-transport.js.map +1 -1
  177. package/dist/server/kernel/redis-cache.d.ts +3 -0
  178. package/dist/server/kernel/redis-cache.d.ts.map +1 -1
  179. package/dist/server/kernel/redis-cache.js +10 -0
  180. package/dist/server/kernel/redis-cache.js.map +1 -1
  181. package/dist/server/runtime/create-app.d.ts.map +1 -1
  182. package/dist/server/runtime/create-app.js +2 -4
  183. package/dist/server/runtime/create-app.js.map +1 -1
  184. package/dist/testing/index.d.ts +1 -0
  185. package/dist/testing/index.d.ts.map +1 -1
  186. package/dist/testing/index.js +1 -0
  187. package/dist/testing/index.js.map +1 -1
  188. package/dist/testing/mock.d.ts +1 -0
  189. package/dist/testing/mock.d.ts.map +1 -1
  190. package/dist/testing/mock.js +7 -2
  191. package/dist/testing/mock.js.map +1 -1
  192. package/dist/testing/realtime-transport-conformance.d.ts +35 -0
  193. package/dist/testing/realtime-transport-conformance.d.ts.map +1 -0
  194. package/dist/testing/realtime-transport-conformance.js +77 -0
  195. package/dist/testing/realtime-transport-conformance.js.map +1 -0
  196. package/dist/wallet/__tests__/wallet-commands.service.test.js +36 -6
  197. package/dist/wallet/__tests__/wallet-commands.service.test.js.map +1 -1
  198. package/dist/wallet/admin-reporting.d.ts +2 -2
  199. package/dist/wallet/contract/index.d.ts +6 -0
  200. package/dist/wallet/contract/index.d.ts.map +1 -1
  201. package/dist/wallet/drizzle/migrations/0004_reflective_shriek.sql +2 -0
  202. package/dist/wallet/drizzle/migrations/meta/0004_snapshot.json +567 -0
  203. package/dist/wallet/drizzle/migrations/meta/_journal.json +7 -0
  204. package/dist/wallet/router/index.d.ts +4 -0
  205. package/dist/wallet/router/index.d.ts.map +1 -1
  206. package/dist/wallet/schema/index.d.ts +3 -3
  207. package/dist/wallet/schema/index.d.ts.map +1 -1
  208. package/dist/wallet/service/wallet-commands.service.d.ts +1 -1
  209. package/dist/wallet/service/wallet-commands.service.d.ts.map +1 -1
  210. package/dist/wallet/service/wallet-commands.service.js +6 -3
  211. package/dist/wallet/service/wallet-commands.service.js.map +1 -1
  212. package/dist/wallet/service/wallet.service.d.ts +1 -1
  213. package/package.json +30 -7
  214. package/dist/engagement/chat/react/use-chat-stream.d.ts +0 -26
  215. package/dist/engagement/chat/react/use-chat-stream.d.ts.map +0 -1
  216. package/dist/engagement/chat/react/use-chat-stream.js +0 -54
  217. package/dist/engagement/chat/react/use-chat-stream.js.map +0 -1
  218. package/dist/engagement/chat/react/use-join-room.d.ts +0 -13
  219. package/dist/engagement/chat/react/use-join-room.d.ts.map +0 -1
  220. package/dist/engagement/chat/react/use-join-room.js +0 -25
  221. package/dist/engagement/chat/react/use-join-room.js.map +0 -1
  222. package/dist/engagement/react.d.ts +0 -3
  223. package/dist/engagement/react.d.ts.map +0 -1
  224. package/dist/engagement/react.js +0 -4
  225. package/dist/engagement/react.js.map +0 -1
@@ -0,0 +1,888 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { mock, makeDrizzle, makeEventBus } from '../../../../testing/mock.js';
3
+ import { ChatCommandsService, CommandDisabledError, InsufficientBalanceError, BelowMinimumError, NoOnlineUsersError, ExceedsLimitError, GiftNotFoundError, GiftAlreadyClaimedError, GiftSelfClaimError, DonateSelfError, ChatPlayerNotFoundError, TooManyRecipientsError, SelfModerationActionError, ChatCommandIdempotencyKeyReuseError, fingerprintCommand, } from '../chat-commands.service.js';
4
+ const ACTOR_ID = '00000000-0000-0000-0000-000000000001';
5
+ const CLAIMER_ID = '00000000-0000-0000-0000-000000000002';
6
+ const ROOM_ID = '00000000-0000-0000-0000-000000000003';
7
+ const MSG_ID = '00000000-0000-0000-0000-000000000004';
8
+ const GIFT_ID = '00000000-0000-0000-0000-000000000005';
9
+ const OTHER_ROOM_ID = '00000000-0000-0000-0000-000000000009';
10
+ const ENABLED_ROW = {
11
+ key: 'gift',
12
+ enabled: true,
13
+ label: 'Gift',
14
+ description: null,
15
+ config: null,
16
+ updatedAt: new Date(),
17
+ };
18
+ const DISABLED_ROW = { ...ENABLED_ROW, enabled: false };
19
+ const SYSTEM_MSG = {
20
+ id: MSG_ID,
21
+ roomId: ROOM_ID,
22
+ actorId: ACTOR_ID,
23
+ content: '',
24
+ metadata: {
25
+ command: 'gift',
26
+ giftId: GIFT_ID,
27
+ senderId: ACTOR_ID,
28
+ senderUsername: 'bob',
29
+ amount: '10.00000000',
30
+ currency: 'USD',
31
+ },
32
+ createdAt: new Date().toISOString(),
33
+ };
34
+ /** Gift row as returned from the DB (dates as Date objects, amount as string). */
35
+ const GIFT_ROW = {
36
+ id: GIFT_ID,
37
+ messageId: MSG_ID,
38
+ senderId: ACTOR_ID,
39
+ senderUsername: 'bob',
40
+ amount: '10.00000000',
41
+ currency: 'USD',
42
+ roomId: ROOM_ID,
43
+ claimedBy: null,
44
+ claimedByUsername: null,
45
+ claimedAt: null,
46
+ createdAt: new Date(),
47
+ };
48
+ function makeWriter() {
49
+ return mock({
50
+ postSystemMessage: vi.fn().mockResolvedValue(SYSTEM_MSG),
51
+ });
52
+ }
53
+ function makeWallet(ok = true) {
54
+ return mock({
55
+ debit: vi
56
+ .fn()
57
+ .mockResolvedValue(ok
58
+ ? { ok: true, newBalance: '90.00000000', currency: 'USD' }
59
+ : { ok: false, available: '5.00000000' }),
60
+ credit: vi.fn().mockResolvedValue({ ok: true, newBalance: '110.00000000' }),
61
+ });
62
+ }
63
+ const DIRECTORY_CREATED_AT = new Date('2026-01-01T00:00:00.000Z');
64
+ function makeDirectory(senderUsername = 'bob', claimerUsername = 'alice') {
65
+ const all = [
66
+ {
67
+ userId: ACTOR_ID,
68
+ username: senderUsername,
69
+ email: 'bob@example.com',
70
+ kycStatus: null,
71
+ language: null,
72
+ avatarUrl: null,
73
+ createdAt: DIRECTORY_CREATED_AT,
74
+ level: 3,
75
+ currency: 'USD',
76
+ },
77
+ {
78
+ userId: CLAIMER_ID,
79
+ username: claimerUsername,
80
+ email: 'alice@example.com',
81
+ kycStatus: null,
82
+ language: null,
83
+ avatarUrl: null,
84
+ createdAt: DIRECTORY_CREATED_AT,
85
+ level: 1,
86
+ currency: 'USD',
87
+ },
88
+ ];
89
+ return mock({
90
+ findPlayerIds: vi.fn().mockResolvedValue([ACTOR_ID]),
91
+ lookupPlayers: vi.fn().mockImplementation((ids) => {
92
+ return Promise.resolve(all.filter((p) => ids.includes(p.userId)));
93
+ }),
94
+ getPlayerByUsername: vi.fn().mockImplementation((username) => {
95
+ return Promise.resolve(all.find((p) => p.username.toLowerCase() === username.toLowerCase()) ?? null);
96
+ }),
97
+ });
98
+ }
99
+ function makeAudit() {
100
+ return mock({
101
+ record: vi.fn().mockResolvedValue(undefined),
102
+ recordInTransaction: vi.fn().mockResolvedValue(undefined),
103
+ });
104
+ }
105
+ function makeBlockWriter() {
106
+ return mock({
107
+ blockUser: vi.fn().mockResolvedValue(undefined),
108
+ ignoreUser: vi.fn().mockResolvedValue(undefined),
109
+ getExcludedUserIds: vi.fn().mockResolvedValue([]),
110
+ });
111
+ }
112
+ function makeGameReporting() {
113
+ return mock({
114
+ getPlayerStats: vi.fn().mockResolvedValue({ totalWagered: '100.00000000', totalBets: 5 }),
115
+ });
116
+ }
117
+ function makeTransport(onlineIds = [CLAIMER_ID]) {
118
+ return mock({
119
+ getOnlineUserIds: vi.fn().mockResolvedValue(onlineIds),
120
+ publish: vi.fn().mockResolvedValue(undefined),
121
+ });
122
+ }
123
+ function makeRoomAccess() {
124
+ return mock({ verifyRoomAccess: vi.fn().mockResolvedValue(undefined) });
125
+ }
126
+ function makeCache(initial = {}) {
127
+ const values = new Map(Object.entries(initial));
128
+ return {
129
+ get: async (key) => values.get(key),
130
+ set: vi.fn(async (key, value) => {
131
+ values.set(key, value);
132
+ }),
133
+ setIfAbsent: vi.fn(async (key, value) => {
134
+ if (values.has(key)) {
135
+ return false;
136
+ }
137
+ values.set(key, value);
138
+ return true;
139
+ }),
140
+ delete: vi.fn(async (key) => {
141
+ for (const item of Array.isArray(key) ? key : [key]) {
142
+ values.delete(item);
143
+ }
144
+ }),
145
+ };
146
+ }
147
+ function makeSvc(overrides = {}) {
148
+ const drizzle = makeDrizzle({
149
+ select: overrides.drizzleRows?.select ?? [],
150
+ returning: overrides.drizzleRows?.returning ?? [],
151
+ execute: overrides.drizzleRows?.execute,
152
+ });
153
+ return new ChatCommandsService(drizzle, overrides.writer ?? makeWriter(), overrides.wallet ?? makeWallet(), overrides.directory ?? makeDirectory(), overrides.audit ?? makeAudit(), overrides.transport ?? makeTransport(), mock(makeEventBus()), overrides.blockWriter ?? makeBlockWriter(), overrides.gameReporting ?? makeGameReporting(), overrides.roomAccess ?? makeRoomAccess(), overrides.cache ?? makeCache());
154
+ }
155
+ describe('ChatCommandsService.listCommands', () => {
156
+ it('returns only enabled commands by default', async () => {
157
+ // mock simulates DB returning only enabled rows (WHERE enabled = true applied at SQL level)
158
+ const svc = makeSvc({
159
+ drizzleRows: { select: [[ENABLED_ROW]] },
160
+ });
161
+ const result = await svc.listCommands();
162
+ expect(result).toHaveLength(1);
163
+ expect(result[0]?.key).toBe('gift');
164
+ });
165
+ it('returns all commands when includeDisabled is true', async () => {
166
+ // mock simulates DB returning all rows (no WHERE clause)
167
+ const svc = makeSvc({
168
+ drizzleRows: { select: [[ENABLED_ROW, DISABLED_ROW]] },
169
+ });
170
+ const result = await svc.listCommands(true);
171
+ expect(result).toHaveLength(2);
172
+ });
173
+ });
174
+ describe('ChatCommandsService.searchMentions', () => {
175
+ it('passes limit to findPlayerIds', async () => {
176
+ const directory = makeDirectory();
177
+ const svc = makeSvc({ directory });
178
+ await svc.searchMentions('ali', 5, CLAIMER_ID);
179
+ expect(directory.findPlayerIds).toHaveBeenCalledWith('ali', 5);
180
+ });
181
+ it('returns empty array when no ids found', async () => {
182
+ const directory = mock({
183
+ findPlayerIds: vi.fn().mockResolvedValue([]),
184
+ lookupPlayers: vi.fn().mockResolvedValue([]),
185
+ });
186
+ const svc = makeSvc({ directory });
187
+ const result = await svc.searchMentions('xyz', 10, CLAIMER_ID);
188
+ expect(result).toEqual([]);
189
+ });
190
+ it('excludes ids the viewer has blocked or ignored', async () => {
191
+ const directory = makeDirectory();
192
+ const blockWriter = mock({
193
+ blockUser: vi.fn().mockResolvedValue(undefined),
194
+ ignoreUser: vi.fn().mockResolvedValue(undefined),
195
+ getExcludedUserIds: vi.fn().mockResolvedValue([ACTOR_ID]),
196
+ });
197
+ const svc = makeSvc({ directory, blockWriter });
198
+ const result = await svc.searchMentions('bo', 5, CLAIMER_ID);
199
+ expect(blockWriter.getExcludedUserIds).toHaveBeenCalledWith(CLAIMER_ID);
200
+ expect(result).toEqual([]);
201
+ });
202
+ });
203
+ describe('ChatCommandsService.searchPlayers', () => {
204
+ it('returns mapped player search results', async () => {
205
+ const directory = makeDirectory();
206
+ const svc = makeSvc({ directory });
207
+ const result = await svc.searchPlayers('bo', 5, CLAIMER_ID);
208
+ expect(directory.findPlayerIds).toHaveBeenCalledWith('bo', 5);
209
+ expect(result).toEqual([{ userId: ACTOR_ID, username: 'bob', avatarUrl: null, level: 3 }]);
210
+ });
211
+ it('returns empty array when no matches', async () => {
212
+ const directory = mock({
213
+ findPlayerIds: vi.fn().mockResolvedValue([]),
214
+ lookupPlayers: vi.fn().mockResolvedValue([]),
215
+ });
216
+ const svc = makeSvc({ directory });
217
+ const result = await svc.searchPlayers('xyz', 10, CLAIMER_ID);
218
+ expect(result).toEqual([]);
219
+ });
220
+ it('excludes ids the viewer has blocked or ignored', async () => {
221
+ const directory = makeDirectory();
222
+ const blockWriter = mock({
223
+ blockUser: vi.fn().mockResolvedValue(undefined),
224
+ ignoreUser: vi.fn().mockResolvedValue(undefined),
225
+ getExcludedUserIds: vi.fn().mockResolvedValue([ACTOR_ID]),
226
+ });
227
+ const svc = makeSvc({ directory, blockWriter });
228
+ const result = await svc.searchPlayers('bo', 5, CLAIMER_ID);
229
+ expect(blockWriter.getExcludedUserIds).toHaveBeenCalledWith(CLAIMER_ID);
230
+ expect(result).toEqual([]);
231
+ });
232
+ });
233
+ describe('ChatCommandsService.getPlayerProfile', () => {
234
+ it('returns the full profile card on the happy path', async () => {
235
+ const gameReporting = makeGameReporting();
236
+ const svc = makeSvc({ directory: makeDirectory(), gameReporting });
237
+ const result = await svc.getPlayerProfile(ACTOR_ID, ACTOR_ID);
238
+ expect(gameReporting.getPlayerStats).toHaveBeenCalledWith(ACTOR_ID);
239
+ expect(result).toEqual({
240
+ userId: ACTOR_ID,
241
+ username: 'bob',
242
+ avatarUrl: null,
243
+ level: 3,
244
+ joinedAt: DIRECTORY_CREATED_AT.toISOString(),
245
+ totalWagered: '100.00000000',
246
+ totalBets: 5,
247
+ currency: 'USD',
248
+ });
249
+ });
250
+ it('throws ChatPlayerNotFoundError for an unknown userId', async () => {
251
+ const directory = mock({
252
+ lookupPlayers: vi.fn().mockResolvedValue([]),
253
+ });
254
+ const svc = makeSvc({ directory });
255
+ await expect(svc.getPlayerProfile(CLAIMER_ID, ACTOR_ID)).rejects.toThrow(ChatPlayerNotFoundError);
256
+ });
257
+ it('redacts private profile fields for another viewer', async () => {
258
+ const gameReporting = makeGameReporting();
259
+ const svc = makeSvc({ directory: makeDirectory(), gameReporting });
260
+ const result = await svc.getPlayerProfile(ACTOR_ID, CLAIMER_ID);
261
+ expect(result).toMatchObject({
262
+ userId: ACTOR_ID,
263
+ username: 'bob',
264
+ avatarUrl: null,
265
+ level: 3,
266
+ joinedAt: null,
267
+ totalWagered: null,
268
+ totalBets: null,
269
+ currency: null,
270
+ });
271
+ expect(gameReporting.getPlayerStats).not.toHaveBeenCalled();
272
+ });
273
+ });
274
+ const IDEMPOTENCY_KEY = '00000000-0000-0000-0000-0000000000aa';
275
+ const IDEMPOTENCY_CACHE_KEY = `chat-command:idempotency:${ACTOR_ID}:gift:${IDEMPOTENCY_KEY}`;
276
+ const RAIN_IDEMPOTENCY_CACHE_KEY = `chat-command:idempotency:${ACTOR_ID}:rain:${IDEMPOTENCY_KEY}`;
277
+ const IDEMPOTENCY_ROW_ID = '00000000-0000-0000-0000-0000000000bb';
278
+ describe('ChatCommandsService.executeCommand (gift)', () => {
279
+ it('throws CommandDisabledError when command row is missing', async () => {
280
+ const svc = makeSvc({ drizzleRows: { select: [[]] } });
281
+ await expect(svc.executeCommand({ type: 'gift', amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID)).rejects.toThrow(CommandDisabledError);
282
+ });
283
+ it('throws CommandDisabledError when command is disabled', async () => {
284
+ const svc = makeSvc({ drizzleRows: { select: [[DISABLED_ROW]] } });
285
+ await expect(svc.executeCommand({ type: 'gift', amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID)).rejects.toThrow(CommandDisabledError);
286
+ });
287
+ it('throws InsufficientBalanceError when wallet debit fails', async () => {
288
+ const svc = makeSvc({
289
+ drizzleRows: {
290
+ select: [[ENABLED_ROW]],
291
+ // Only the idempotency guard insert is reached; wallet fails before the gift insert.
292
+ returning: [[{ id: IDEMPOTENCY_ROW_ID }]],
293
+ },
294
+ wallet: makeWallet(false),
295
+ });
296
+ await expect(svc.executeCommand({ type: 'gift', amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID)).rejects.toThrow(InsufficientBalanceError);
297
+ });
298
+ it('posts system message with new gift metadata on success', async () => {
299
+ const writer = makeWriter();
300
+ const svc = makeSvc({
301
+ drizzleRows: {
302
+ select: [[ENABLED_ROW]],
303
+ // First returning: idempotency guard insert; second: chatGift insert.
304
+ returning: [[{ ...GIFT_ROW }]],
305
+ },
306
+ writer,
307
+ });
308
+ const result = await svc.executeCommand({ type: 'gift', amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
309
+ expect(writer.postSystemMessage).toHaveBeenCalledOnce();
310
+ expect(writer.postSystemMessage).toHaveBeenCalledWith(expect.objectContaining({
311
+ metadata: expect.objectContaining({
312
+ command: 'gift',
313
+ giftId: GIFT_ID,
314
+ senderId: ACTOR_ID,
315
+ senderUsername: 'bob',
316
+ }),
317
+ }));
318
+ expect(result.id).toBe(MSG_ID);
319
+ });
320
+ it('enforces minAmount from config', async () => {
321
+ const svc = makeSvc({
322
+ drizzleRows: {
323
+ select: [[{ ...ENABLED_ROW, config: { minAmount: '5.00000000' } }]],
324
+ },
325
+ });
326
+ await expect(svc.executeCommand({
327
+ type: 'gift',
328
+ amount: '1.00000000',
329
+ roomId: ROOM_ID,
330
+ idempotencyKey: IDEMPOTENCY_KEY,
331
+ }, ACTOR_ID)).rejects.toThrow(BelowMinimumError);
332
+ });
333
+ });
334
+ describe('ChatCommandsService.executeCommand (gift idempotency)', () => {
335
+ it('inserts the idempotency guard row and debits once when a fresh key is supplied', async () => {
336
+ const wallet = makeWallet();
337
+ const svc = makeSvc({
338
+ drizzleRows: {
339
+ select: [[ENABLED_ROW], []],
340
+ returning: [[{ id: IDEMPOTENCY_ROW_ID }], [{ ...GIFT_ROW }]],
341
+ },
342
+ wallet,
343
+ });
344
+ const result = await svc.executeCommand({ type: 'gift', amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
345
+ expect(wallet.debit).toHaveBeenCalledOnce();
346
+ expect(result.id).toBe(MSG_ID);
347
+ });
348
+ it('replays the stored result without debiting the wallet again', async () => {
349
+ const wallet = makeWallet();
350
+ const giftInput = {
351
+ type: 'gift',
352
+ amount: '10.00000000',
353
+ roomId: ROOM_ID,
354
+ idempotencyKey: IDEMPOTENCY_KEY,
355
+ };
356
+ const storedRow = {
357
+ id: IDEMPOTENCY_ROW_ID,
358
+ actorId: ACTOR_ID,
359
+ commandType: 'gift',
360
+ idempotencyKey: IDEMPOTENCY_KEY,
361
+ amount: '10.00000000',
362
+ fingerprint: fingerprintCommand(giftInput),
363
+ result: SYSTEM_MSG,
364
+ createdAt: new Date(),
365
+ };
366
+ const svc = makeSvc({
367
+ drizzleRows: { select: [[ENABLED_ROW]] },
368
+ cache: makeCache({
369
+ [IDEMPOTENCY_CACHE_KEY]: { fingerprint: storedRow.fingerprint, result: SYSTEM_MSG },
370
+ }),
371
+ wallet,
372
+ });
373
+ const result = await svc.executeCommand(giftInput, ACTOR_ID);
374
+ expect(wallet.debit).not.toHaveBeenCalled();
375
+ expect(result).toEqual(SYSTEM_MSG);
376
+ });
377
+ it('throws ChatCommandIdempotencyKeyReuseError when the same key is reused with a different amount', async () => {
378
+ const storedRow = {
379
+ id: IDEMPOTENCY_ROW_ID,
380
+ actorId: ACTOR_ID,
381
+ commandType: 'gift',
382
+ idempotencyKey: IDEMPOTENCY_KEY,
383
+ amount: '5.00000000',
384
+ fingerprint: fingerprintCommand({
385
+ type: 'gift',
386
+ amount: '5.00000000',
387
+ roomId: ROOM_ID,
388
+ idempotencyKey: IDEMPOTENCY_KEY,
389
+ }),
390
+ result: SYSTEM_MSG,
391
+ createdAt: new Date(),
392
+ };
393
+ const svc = makeSvc({
394
+ drizzleRows: { select: [[ENABLED_ROW]] },
395
+ cache: makeCache({
396
+ [IDEMPOTENCY_CACHE_KEY]: { fingerprint: storedRow.fingerprint, result: SYSTEM_MSG },
397
+ }),
398
+ });
399
+ await expect(svc.executeCommand({ type: 'gift', amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID)).rejects.toThrow(ChatCommandIdempotencyKeyReuseError);
400
+ });
401
+ it('throws ChatCommandIdempotencyKeyReuseError when the same key+amount is reused for a different room', async () => {
402
+ const storedRow = {
403
+ id: IDEMPOTENCY_ROW_ID,
404
+ actorId: ACTOR_ID,
405
+ commandType: 'gift',
406
+ idempotencyKey: IDEMPOTENCY_KEY,
407
+ amount: '10.00000000',
408
+ fingerprint: fingerprintCommand({
409
+ type: 'gift',
410
+ amount: '10.00000000',
411
+ roomId: OTHER_ROOM_ID,
412
+ idempotencyKey: IDEMPOTENCY_KEY,
413
+ }),
414
+ result: SYSTEM_MSG,
415
+ createdAt: new Date(),
416
+ };
417
+ const svc = makeSvc({
418
+ drizzleRows: { select: [[ENABLED_ROW]] },
419
+ cache: makeCache({
420
+ [IDEMPOTENCY_CACHE_KEY]: { fingerprint: storedRow.fingerprint, result: SYSTEM_MSG },
421
+ }),
422
+ });
423
+ await expect(svc.executeCommand({ type: 'gift', amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID)).rejects.toThrow(ChatCommandIdempotencyKeyReuseError);
424
+ });
425
+ });
426
+ describe('ChatCommandsService.claimGift', () => {
427
+ it('credits the claimer and returns claim info on happy path', async () => {
428
+ const wallet = makeWallet();
429
+ const svc = makeSvc({
430
+ drizzleRows: {
431
+ // select[0]: gift lookup; returning[0]: atomic update result
432
+ select: [[GIFT_ROW]],
433
+ returning: [
434
+ [
435
+ {
436
+ ...GIFT_ROW,
437
+ claimedBy: CLAIMER_ID,
438
+ claimedByUsername: 'alice',
439
+ claimedAt: new Date(),
440
+ },
441
+ ],
442
+ ],
443
+ },
444
+ wallet,
445
+ });
446
+ const result = await svc.claimGift(GIFT_ID, CLAIMER_ID);
447
+ expect(wallet.credit).toHaveBeenCalledOnce();
448
+ expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ userId: CLAIMER_ID, type: 'gift' }));
449
+ expect(result.claimedBy).toBe(CLAIMER_ID);
450
+ expect(result.claimedByUsername).toBe('alice');
451
+ expect(result.claimedAt).toEqual(expect.any(String));
452
+ });
453
+ it('throws GiftSelfClaimError when sender tries to claim their own gift', async () => {
454
+ const svc = makeSvc({
455
+ drizzleRows: {
456
+ select: [[GIFT_ROW]], // senderId === ACTOR_ID
457
+ returning: [],
458
+ },
459
+ });
460
+ await expect(svc.claimGift(GIFT_ID, ACTOR_ID)).rejects.toThrow(GiftSelfClaimError);
461
+ });
462
+ it('throws GiftAlreadyClaimedError when update returns no rows (race lost)', async () => {
463
+ const svc = makeSvc({
464
+ drizzleRows: {
465
+ select: [[GIFT_ROW]],
466
+ returning: [[]], // empty = already claimed
467
+ },
468
+ });
469
+ await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftAlreadyClaimedError);
470
+ });
471
+ it('throws GiftNotFoundError when gift does not exist', async () => {
472
+ const svc = makeSvc({
473
+ drizzleRows: {
474
+ select: [[]], // no gift row
475
+ returning: [],
476
+ },
477
+ });
478
+ await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftNotFoundError);
479
+ });
480
+ });
481
+ describe('ChatCommandsService.adminUpdateCommand', () => {
482
+ it('upserts the command row and returns the descriptor', async () => {
483
+ const updatedRow = { ...ENABLED_ROW, key: 'gift', enabled: false };
484
+ const svc = makeSvc({
485
+ drizzleRows: { returning: [[updatedRow]] },
486
+ });
487
+ const result = await svc.adminUpdateCommand({ key: 'gift', enabled: false }, ACTOR_ID);
488
+ expect(result.key).toBe('gift');
489
+ expect(result.enabled).toBe(false);
490
+ });
491
+ it('records an audit entry on update', async () => {
492
+ const updatedRow = { ...ENABLED_ROW, key: 'rain', enabled: false };
493
+ const audit = makeAudit();
494
+ const drizzle = makeDrizzle({ select: [], returning: [[updatedRow]] });
495
+ const svcWithAudit = new ChatCommandsService(drizzle, makeWriter(), makeWallet(), makeDirectory(), audit, makeTransport(), mock(makeEventBus()), makeBlockWriter(), makeGameReporting(), makeRoomAccess(), makeCache());
496
+ await svcWithAudit.adminUpdateCommand({ key: 'rain', enabled: false }, ACTOR_ID);
497
+ expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({
498
+ actorId: ACTOR_ID,
499
+ actorType: 'admin',
500
+ action: 'chat.command.updated',
501
+ resourceId: 'rain',
502
+ }));
503
+ });
504
+ });
505
+ describe('ChatCommandsService.handleRain', () => {
506
+ const RAIN_ROW = { ...ENABLED_ROW, key: 'rain', label: 'Rain' };
507
+ it('throws NoOnlineUsersError when no other users are online', async () => {
508
+ const svc = makeSvc({
509
+ drizzleRows: { select: [[RAIN_ROW]] },
510
+ transport: makeTransport([ACTOR_ID]),
511
+ });
512
+ await expect(svc.executeCommand({
513
+ type: 'rain',
514
+ amount: '10.00000000',
515
+ recipientCount: 5,
516
+ roomId: ROOM_ID,
517
+ idempotencyKey: IDEMPOTENCY_KEY,
518
+ }, ACTOR_ID)).rejects.toThrow(NoOnlineUsersError);
519
+ });
520
+ it('replays before discovering online presence', async () => {
521
+ const transport = makeTransport();
522
+ const storedRow = {
523
+ id: IDEMPOTENCY_ROW_ID,
524
+ actorId: ACTOR_ID,
525
+ commandType: 'rain',
526
+ idempotencyKey: IDEMPOTENCY_KEY,
527
+ amount: '10.99000000',
528
+ fingerprint: fingerprintCommand({
529
+ type: 'rain',
530
+ amount: '10.99000000',
531
+ recipientCount: 10,
532
+ roomId: ROOM_ID,
533
+ idempotencyKey: IDEMPOTENCY_KEY,
534
+ }),
535
+ result: SYSTEM_MSG,
536
+ createdAt: new Date(),
537
+ };
538
+ const svc = makeSvc({
539
+ drizzleRows: { select: [[RAIN_ROW]] },
540
+ transport,
541
+ cache: makeCache({
542
+ [RAIN_IDEMPOTENCY_CACHE_KEY]: { fingerprint: storedRow.fingerprint, result: SYSTEM_MSG },
543
+ }),
544
+ });
545
+ await expect(svc.executeCommand({
546
+ type: 'rain',
547
+ amount: '10.99000000',
548
+ recipientCount: 10,
549
+ roomId: ROOM_ID,
550
+ idempotencyKey: IDEMPOTENCY_KEY,
551
+ }, ACTOR_ID)).resolves.toEqual(SYSTEM_MSG);
552
+ expect(transport.getOnlineUserIds).not.toHaveBeenCalled();
553
+ });
554
+ it('distributes to online recipients excluding the actor', async () => {
555
+ const RECIPIENT_2 = '00000000-0000-0000-0000-000000000006';
556
+ const wallet = makeWallet();
557
+ const svc = makeSvc({
558
+ drizzleRows: {
559
+ select: [[RAIN_ROW]],
560
+ returning: [[{ id: IDEMPOTENCY_ROW_ID }]],
561
+ execute: [[{ per_recipient: '5.00000000', total_distributed: '10.00000000' }]],
562
+ },
563
+ wallet,
564
+ transport: makeTransport([ACTOR_ID, CLAIMER_ID, RECIPIENT_2]),
565
+ });
566
+ await svc.executeCommand({
567
+ type: 'rain',
568
+ amount: '10.00000000',
569
+ recipientCount: 2,
570
+ roomId: ROOM_ID,
571
+ idempotencyKey: IDEMPOTENCY_KEY,
572
+ }, ACTOR_ID);
573
+ expect(wallet.credit).toHaveBeenCalledTimes(2);
574
+ expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), {
575
+ userId: CLAIMER_ID,
576
+ amount: '5.00000000',
577
+ currency: 'USD',
578
+ type: 'rain',
579
+ });
580
+ });
581
+ it('uses Postgres floor division to avoid float imprecision', async () => {
582
+ const wallet = makeWallet();
583
+ const svc = makeSvc({
584
+ drizzleRows: {
585
+ select: [[RAIN_ROW]],
586
+ returning: [[{ id: IDEMPOTENCY_ROW_ID }]],
587
+ execute: [[{ per_recipient: '3.33333333', total_distributed: '9.99999999' }]],
588
+ },
589
+ wallet,
590
+ transport: makeTransport([
591
+ CLAIMER_ID,
592
+ '00000000-0000-0000-0000-000000000006',
593
+ '00000000-0000-0000-0000-000000000007',
594
+ ]),
595
+ });
596
+ await svc.executeCommand({
597
+ type: 'rain',
598
+ amount: '10.00000000',
599
+ recipientCount: 3,
600
+ roomId: ROOM_ID,
601
+ idempotencyKey: IDEMPOTENCY_KEY,
602
+ }, ACTOR_ID);
603
+ expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), {
604
+ userId: expect.any(String),
605
+ amount: '3.33333333',
606
+ currency: 'USD',
607
+ type: 'rain',
608
+ });
609
+ });
610
+ it('debits only the amount actually distributed (floor(amount/n)*n), never the raw player-typed amount', async () => {
611
+ const wallet = makeWallet();
612
+ const svc = makeSvc({
613
+ drizzleRows: {
614
+ select: [[RAIN_ROW]],
615
+ returning: [[{ id: IDEMPOTENCY_ROW_ID }]],
616
+ execute: [[{ per_recipient: '1.00000000', total_distributed: '10.00000000' }]],
617
+ },
618
+ wallet,
619
+ transport: makeTransport(Array.from({ length: 10 }, (_, i) => `00000000-0000-0000-0000-0000000000${10 + i}`)),
620
+ });
621
+ await svc.executeCommand({
622
+ type: 'rain',
623
+ amount: '10.99000000',
624
+ recipientCount: 10,
625
+ roomId: ROOM_ID,
626
+ idempotencyKey: IDEMPOTENCY_KEY,
627
+ }, ACTOR_ID);
628
+ expect(wallet.debit).toHaveBeenCalledWith(expect.anything(), {
629
+ userId: ACTOR_ID,
630
+ amount: '10.00000000',
631
+ type: 'rain',
632
+ });
633
+ });
634
+ it('throws BelowMinimumError when amount is below config minAmount', async () => {
635
+ const svc = makeSvc({
636
+ drizzleRows: {
637
+ select: [[{ ...RAIN_ROW, config: { minAmount: '5.00000000' } }]],
638
+ },
639
+ });
640
+ await expect(svc.executeCommand({
641
+ type: 'rain',
642
+ amount: '1.00000000',
643
+ recipientCount: 1,
644
+ roomId: ROOM_ID,
645
+ idempotencyKey: IDEMPOTENCY_KEY,
646
+ }, ACTOR_ID)).rejects.toThrow(BelowMinimumError);
647
+ });
648
+ it('throws ExceedsLimitError when amount is above config maxAmount', async () => {
649
+ const svc = makeSvc({
650
+ drizzleRows: {
651
+ select: [[{ ...RAIN_ROW, config: { maxAmount: '10.00000000' } }]],
652
+ },
653
+ });
654
+ await expect(svc.executeCommand({
655
+ type: 'rain',
656
+ amount: '50.00000000',
657
+ recipientCount: 1,
658
+ roomId: ROOM_ID,
659
+ idempotencyKey: IDEMPOTENCY_KEY,
660
+ }, ACTOR_ID)).rejects.toThrow(ExceedsLimitError);
661
+ });
662
+ it('throws ExceedsLimitError when recipientCount exceeds config maxRecipients', async () => {
663
+ const svc = makeSvc({
664
+ drizzleRows: {
665
+ select: [[{ ...RAIN_ROW, config: { maxRecipients: 10 } }]],
666
+ },
667
+ });
668
+ await expect(svc.executeCommand({
669
+ type: 'rain',
670
+ amount: '100.00000000',
671
+ recipientCount: 11,
672
+ roomId: ROOM_ID,
673
+ idempotencyKey: IDEMPOTENCY_KEY,
674
+ }, ACTOR_ID)).rejects.toThrow(ExceedsLimitError);
675
+ });
676
+ it('throws TooManyRecipientsError when recipientCount exceeds the whole-dollar amount', async () => {
677
+ const svc = makeSvc({
678
+ drizzleRows: { select: [[RAIN_ROW]] },
679
+ });
680
+ await expect(svc.executeCommand({
681
+ type: 'rain',
682
+ amount: '3.00000000',
683
+ recipientCount: 4,
684
+ roomId: ROOM_ID,
685
+ idempotencyKey: IDEMPOTENCY_KEY,
686
+ }, ACTOR_ID)).rejects.toThrow(TooManyRecipientsError);
687
+ });
688
+ });
689
+ const DONATE_ROW = {
690
+ key: 'donate',
691
+ enabled: true,
692
+ label: 'Donate',
693
+ description: 'Send a direct tip to a specific player',
694
+ config: null,
695
+ updatedAt: new Date(),
696
+ };
697
+ const DONATE_SYSTEM_MSG = {
698
+ id: MSG_ID,
699
+ roomId: ROOM_ID,
700
+ actorId: ACTOR_ID,
701
+ content: '',
702
+ metadata: {
703
+ command: 'donate',
704
+ recipientId: CLAIMER_ID,
705
+ recipientUsername: 'alice',
706
+ amount: '10.00000000',
707
+ currency: 'USD',
708
+ },
709
+ createdAt: new Date().toISOString(),
710
+ };
711
+ /**
712
+ * Directory mock where findPlayerIds resolves the RECIPIENT (CLAIMER_ID = 'alice').
713
+ * Used for donate tests that target 'alice' — the default makeDirectory() always
714
+ * returns ACTOR_ID which only has username 'bob', so alice is never found.
715
+ */
716
+ function makeRecipientDirectory() {
717
+ const all = [
718
+ {
719
+ userId: ACTOR_ID,
720
+ username: 'bob',
721
+ email: 'bob@example.com',
722
+ kycStatus: null,
723
+ language: null,
724
+ avatarUrl: null,
725
+ createdAt: DIRECTORY_CREATED_AT,
726
+ level: 3,
727
+ currency: 'USD',
728
+ },
729
+ {
730
+ userId: CLAIMER_ID,
731
+ username: 'alice',
732
+ email: 'alice@example.com',
733
+ kycStatus: null,
734
+ language: null,
735
+ avatarUrl: null,
736
+ createdAt: DIRECTORY_CREATED_AT,
737
+ level: 1,
738
+ currency: 'USD',
739
+ },
740
+ ];
741
+ return mock({
742
+ findPlayerIds: vi.fn().mockResolvedValue([CLAIMER_ID]),
743
+ lookupPlayers: vi.fn().mockImplementation((ids) => {
744
+ return Promise.resolve(all.filter((p) => ids.includes(p.userId)));
745
+ }),
746
+ getPlayerByUsername: vi.fn().mockImplementation((username) => {
747
+ return Promise.resolve(all.find((p) => p.username.toLowerCase() === username.toLowerCase()) ?? null);
748
+ }),
749
+ });
750
+ }
751
+ describe('ChatCommandsService.handleDonate', () => {
752
+ it('debits sender, credits recipient, and returns system message on success', async () => {
753
+ const wallet = makeWallet();
754
+ const writer = mock({
755
+ postSystemMessage: vi.fn().mockResolvedValue(DONATE_SYSTEM_MSG),
756
+ });
757
+ const svc = makeSvc({
758
+ drizzleRows: {
759
+ select: [[DONATE_ROW]],
760
+ returning: [[{ id: IDEMPOTENCY_ROW_ID }]],
761
+ },
762
+ wallet,
763
+ writer,
764
+ directory: makeRecipientDirectory(),
765
+ });
766
+ const result = await svc.executeCommand({
767
+ type: 'donate',
768
+ targetUsername: 'alice',
769
+ amount: '10.00000000',
770
+ roomId: ROOM_ID,
771
+ idempotencyKey: IDEMPOTENCY_KEY,
772
+ }, ACTOR_ID);
773
+ expect(wallet.debit).toHaveBeenCalledWith(expect.anything(), {
774
+ userId: ACTOR_ID,
775
+ amount: '10.00000000',
776
+ type: 'tip',
777
+ });
778
+ expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), {
779
+ userId: CLAIMER_ID,
780
+ amount: '10.00000000',
781
+ currency: 'USD',
782
+ type: 'tip',
783
+ });
784
+ expect(result.id).toBe(MSG_ID);
785
+ });
786
+ it('throws DonateSelfError when sender targets themselves', async () => {
787
+ // Default makeDirectory() returns ACTOR_ID ('bob') from findPlayerIds — correct for self-target.
788
+ const svc = makeSvc({
789
+ drizzleRows: { select: [[DONATE_ROW]] },
790
+ });
791
+ await expect(svc.executeCommand({
792
+ type: 'donate',
793
+ targetUsername: 'bob',
794
+ amount: '10.00000000',
795
+ roomId: ROOM_ID,
796
+ idempotencyKey: IDEMPOTENCY_KEY,
797
+ }, ACTOR_ID)).rejects.toThrow(DonateSelfError);
798
+ });
799
+ it('throws ChatPlayerNotFoundError when target username does not exist', async () => {
800
+ const directory = mock({
801
+ findPlayerIds: vi.fn().mockResolvedValue([]),
802
+ lookupPlayers: vi.fn().mockResolvedValue([]),
803
+ getPlayerByUsername: vi.fn().mockResolvedValue(null),
804
+ });
805
+ const svc = makeSvc({
806
+ drizzleRows: { select: [[DONATE_ROW]] },
807
+ directory,
808
+ });
809
+ await expect(svc.executeCommand({
810
+ type: 'donate',
811
+ targetUsername: 'ghost',
812
+ amount: '10.00000000',
813
+ roomId: ROOM_ID,
814
+ idempotencyKey: IDEMPOTENCY_KEY,
815
+ }, ACTOR_ID)).rejects.toThrow(ChatPlayerNotFoundError);
816
+ });
817
+ it('throws InsufficientBalanceError when sender wallet debit fails', async () => {
818
+ const svc = makeSvc({
819
+ drizzleRows: {
820
+ select: [[DONATE_ROW]],
821
+ returning: [[{ id: IDEMPOTENCY_ROW_ID }]],
822
+ },
823
+ wallet: makeWallet(false),
824
+ directory: makeRecipientDirectory(),
825
+ });
826
+ await expect(svc.executeCommand({
827
+ type: 'donate',
828
+ targetUsername: 'alice',
829
+ amount: '10.00000000',
830
+ roomId: ROOM_ID,
831
+ idempotencyKey: IDEMPOTENCY_KEY,
832
+ }, ACTOR_ID)).rejects.toThrow(InsufficientBalanceError);
833
+ });
834
+ it('throws BelowMinimumError when amount is below config minAmount', async () => {
835
+ // Amount check fires before directory lookup, so directory mock does not matter here.
836
+ const svc = makeSvc({
837
+ drizzleRows: {
838
+ select: [[{ ...DONATE_ROW, config: { minAmount: '5.00000000' } }]],
839
+ },
840
+ });
841
+ await expect(svc.executeCommand({
842
+ type: 'donate',
843
+ targetUsername: 'alice',
844
+ amount: '1.00000000',
845
+ roomId: ROOM_ID,
846
+ idempotencyKey: IDEMPOTENCY_KEY,
847
+ }, ACTOR_ID)).rejects.toThrow(BelowMinimumError);
848
+ });
849
+ });
850
+ const BLOCK_ROW = { ...ENABLED_ROW, key: 'block', label: 'Block' };
851
+ const IGNORE_ROW = { ...ENABLED_ROW, key: 'ignore', label: 'Ignore' };
852
+ describe('ChatCommandsService.handleBlockAction', () => {
853
+ it('dispatches "block" to blockWriter.blockUser, not ignoreUser', async () => {
854
+ const blockWriter = makeBlockWriter();
855
+ const svc = makeSvc({
856
+ drizzleRows: { select: [[BLOCK_ROW]] },
857
+ directory: makeRecipientDirectory(),
858
+ blockWriter,
859
+ });
860
+ await svc.executeCommand({ type: 'block', targetUsername: 'alice', roomId: ROOM_ID }, ACTOR_ID);
861
+ expect(blockWriter.blockUser).toHaveBeenCalledWith(ACTOR_ID, CLAIMER_ID);
862
+ expect(blockWriter.ignoreUser).not.toHaveBeenCalled();
863
+ });
864
+ it('dispatches "ignore" to blockWriter.ignoreUser, not blockUser', async () => {
865
+ const blockWriter = makeBlockWriter();
866
+ const svc = makeSvc({
867
+ drizzleRows: { select: [[IGNORE_ROW]] },
868
+ directory: makeRecipientDirectory(),
869
+ blockWriter,
870
+ });
871
+ await svc.executeCommand({ type: 'ignore', targetUsername: 'alice', roomId: ROOM_ID }, ACTOR_ID);
872
+ expect(blockWriter.ignoreUser).toHaveBeenCalledWith(ACTOR_ID, CLAIMER_ID);
873
+ expect(blockWriter.blockUser).not.toHaveBeenCalled();
874
+ });
875
+ it('throws SelfModerationActionError on self-block', async () => {
876
+ const svc = makeSvc({
877
+ drizzleRows: { select: [[BLOCK_ROW]] },
878
+ });
879
+ await expect(svc.executeCommand({ type: 'block', targetUsername: 'bob', roomId: ROOM_ID }, ACTOR_ID)).rejects.toThrow(SelfModerationActionError);
880
+ });
881
+ it('throws SelfModerationActionError on self-ignore', async () => {
882
+ const svc = makeSvc({
883
+ drizzleRows: { select: [[IGNORE_ROW]] },
884
+ });
885
+ await expect(svc.executeCommand({ type: 'ignore', targetUsername: 'bob', roomId: ROOM_ID }, ACTOR_ID)).rejects.toThrow(SelfModerationActionError);
886
+ });
887
+ });
888
+ //# sourceMappingURL=chat-commands.service.test.js.map