@openora/core 0.4.1-canary.59 → 0.4.1-canary.62

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 (214) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/plugin.d.ts +2 -0
  3. package/dist/admin-console/plugin.d.ts.map +1 -1
  4. package/dist/analytics/plugin.d.ts +2 -0
  5. package/dist/analytics/plugin.d.ts.map +1 -1
  6. package/dist/audit/plugin.d.ts +2 -0
  7. package/dist/audit/plugin.d.ts.map +1 -1
  8. package/dist/audit/plugin.js +13 -0
  9. package/dist/audit/plugin.js.map +1 -1
  10. package/dist/casino/gaming/plugin.d.ts +2 -0
  11. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  12. package/dist/casino/lobby/plugin.d.ts +2 -0
  13. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  14. package/dist/cms/plugin.d.ts +2 -0
  15. package/dist/cms/plugin.d.ts.map +1 -1
  16. package/dist/compliance/plugin.d.ts +2 -0
  17. package/dist/compliance/plugin.d.ts.map +1 -1
  18. package/dist/contracts/adapters/audit.d.ts +1 -1
  19. package/dist/contracts/adapters/audit.d.ts.map +1 -1
  20. package/dist/contracts/adapters/audit.js.map +1 -1
  21. package/dist/contracts/adapters/gift-commands.d.ts +54 -0
  22. package/dist/contracts/adapters/gift-commands.d.ts.map +1 -0
  23. package/dist/contracts/adapters/gift-commands.js +11 -0
  24. package/dist/contracts/adapters/gift-commands.js.map +1 -0
  25. package/dist/contracts/adapters/index.d.ts +4 -0
  26. package/dist/contracts/adapters/index.d.ts.map +1 -1
  27. package/dist/contracts/adapters/index.js +2 -0
  28. package/dist/contracts/adapters/index.js.map +1 -1
  29. package/dist/contracts/adapters/rain-commands.d.ts +23 -0
  30. package/dist/contracts/adapters/rain-commands.d.ts.map +1 -0
  31. package/dist/contracts/adapters/rain-commands.js +12 -0
  32. package/dist/contracts/adapters/rain-commands.js.map +1 -0
  33. package/dist/contracts/schemas/chat-command-metadata.d.ts +12 -0
  34. package/dist/contracts/schemas/chat-command-metadata.d.ts.map +1 -1
  35. package/dist/contracts/schemas/chat-command-metadata.js +10 -0
  36. package/dist/contracts/schemas/chat-command-metadata.js.map +1 -1
  37. package/dist/contracts/schemas/chat-command.d.ts +25 -0
  38. package/dist/contracts/schemas/chat-command.d.ts.map +1 -1
  39. package/dist/contracts/schemas/chat-command.js +13 -1
  40. package/dist/contracts/schemas/chat-command.js.map +1 -1
  41. package/dist/contracts/schemas/events.d.ts +15 -3
  42. package/dist/contracts/schemas/events.d.ts.map +1 -1
  43. package/dist/contracts/schemas/events.js +17 -6
  44. package/dist/contracts/schemas/events.js.map +1 -1
  45. package/dist/contracts/schemas/identity.d.ts +2 -1
  46. package/dist/contracts/schemas/identity.d.ts.map +1 -1
  47. package/dist/contracts/schemas/identity.js +5 -1
  48. package/dist/contracts/schemas/identity.js.map +1 -1
  49. package/dist/engagement/chat/__tests__/chat.service.int.test.js +211 -8
  50. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  51. package/dist/engagement/chat/contract/index.d.ts +154 -8
  52. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  53. package/dist/engagement/chat/contract/index.js +44 -2
  54. package/dist/engagement/chat/contract/index.js.map +1 -1
  55. package/dist/engagement/chat/drizzle/migrations/0004_panoramic_robin_chapel.sql +6 -0
  56. package/dist/engagement/chat/drizzle/migrations/meta/0004_snapshot.json +666 -0
  57. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +7 -0
  58. package/dist/engagement/chat/plugin.d.ts +2 -0
  59. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  60. package/dist/engagement/chat/router/index.d.ts +126 -8
  61. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  62. package/dist/engagement/chat/router/index.js +10 -2
  63. package/dist/engagement/chat/router/index.js.map +1 -1
  64. package/dist/engagement/chat/schema/index.d.ts +50 -0
  65. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  66. package/dist/engagement/chat/schema/index.js +15 -3
  67. package/dist/engagement/chat/schema/index.js.map +1 -1
  68. package/dist/engagement/chat/service/chat.service.d.ts +95 -9
  69. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  70. package/dist/engagement/chat/service/chat.service.js +115 -24
  71. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  72. package/dist/engagement/chat-commands/contract/index.d.ts +143 -71
  73. package/dist/engagement/chat-commands/contract/index.d.ts.map +1 -1
  74. package/dist/engagement/chat-commands/contract/index.js +48 -70
  75. package/dist/engagement/chat-commands/contract/index.js.map +1 -1
  76. package/dist/engagement/chat-commands/plugin.d.ts +3 -1
  77. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  78. package/dist/engagement/chat-commands/plugin.js +4 -4
  79. package/dist/engagement/chat-commands/plugin.js.map +1 -1
  80. package/dist/engagement/chat-commands/router/index.d.ts +149 -83
  81. package/dist/engagement/chat-commands/router/index.d.ts.map +1 -1
  82. package/dist/engagement/chat-commands/router/index.js +36 -26
  83. package/dist/engagement/chat-commands/router/index.js.map +1 -1
  84. package/dist/engagement/chat-commands/seed/index.d.ts.map +1 -1
  85. package/dist/engagement/chat-commands/seed/index.js +12 -0
  86. package/dist/engagement/chat-commands/seed/index.js.map +1 -1
  87. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js +197 -733
  88. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js.map +1 -1
  89. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts +43 -68
  90. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts.map +1 -1
  91. package/dist/engagement/chat-commands/service/chat-commands.service.js +138 -571
  92. package/dist/engagement/chat-commands/service/chat-commands.service.js.map +1 -1
  93. package/dist/engagement/contracts.d.ts +1 -0
  94. package/dist/engagement/contracts.d.ts.map +1 -1
  95. package/dist/engagement/contracts.js +1 -0
  96. package/dist/engagement/contracts.js.map +1 -1
  97. package/dist/engagement/index.d.ts +1 -0
  98. package/dist/engagement/index.d.ts.map +1 -1
  99. package/dist/engagement/index.js +1 -0
  100. package/dist/engagement/index.js.map +1 -1
  101. package/dist/engagement/notifications/plugin.d.ts +2 -0
  102. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  103. package/dist/engagement/server.d.ts +1 -0
  104. package/dist/engagement/server.d.ts.map +1 -1
  105. package/dist/engagement/server.js +1 -0
  106. package/dist/engagement/server.js.map +1 -1
  107. package/dist/engagement/social-transfers/contract/index.d.ts +67 -0
  108. package/dist/engagement/social-transfers/contract/index.d.ts.map +1 -0
  109. package/dist/engagement/social-transfers/contract/index.js +21 -0
  110. package/dist/engagement/social-transfers/contract/index.js.map +1 -0
  111. package/dist/engagement/social-transfers/drizzle/migrations/0000_clammy_rattler.sql +46 -0
  112. package/dist/engagement/social-transfers/drizzle/migrations/meta/0000_snapshot.json +287 -0
  113. package/dist/engagement/social-transfers/drizzle/migrations/meta/_journal.json +13 -0
  114. package/dist/engagement/social-transfers/drizzle.config.d.ts +4 -0
  115. package/dist/engagement/social-transfers/drizzle.config.d.ts.map +1 -0
  116. package/dist/engagement/social-transfers/drizzle.config.js +21 -0
  117. package/dist/engagement/social-transfers/drizzle.config.js.map +1 -0
  118. package/dist/engagement/social-transfers/index.d.ts +3 -0
  119. package/dist/engagement/social-transfers/index.d.ts.map +1 -0
  120. package/dist/engagement/social-transfers/index.js +6 -0
  121. package/dist/engagement/social-transfers/index.js.map +1 -0
  122. package/dist/engagement/social-transfers/migrate.d.ts +5 -0
  123. package/dist/engagement/social-transfers/migrate.d.ts.map +1 -0
  124. package/dist/engagement/social-transfers/migrate.js +17 -0
  125. package/dist/engagement/social-transfers/migrate.js.map +1 -0
  126. package/dist/engagement/social-transfers/plugin.d.ts +111 -0
  127. package/dist/engagement/social-transfers/plugin.d.ts.map +1 -0
  128. package/dist/engagement/social-transfers/plugin.js +24 -0
  129. package/dist/engagement/social-transfers/plugin.js.map +1 -0
  130. package/dist/engagement/social-transfers/router/index.d.ts +59 -0
  131. package/dist/engagement/social-transfers/router/index.d.ts.map +1 -0
  132. package/dist/engagement/social-transfers/router/index.js +25 -0
  133. package/dist/engagement/social-transfers/router/index.js.map +1 -0
  134. package/dist/engagement/social-transfers/schema/index.d.ts +609 -0
  135. package/dist/engagement/social-transfers/schema/index.d.ts.map +1 -0
  136. package/dist/engagement/social-transfers/schema/index.js +61 -0
  137. package/dist/engagement/social-transfers/schema/index.js.map +1 -0
  138. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.d.ts +2 -0
  139. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.d.ts.map +1 -0
  140. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js +672 -0
  141. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js.map +1 -0
  142. package/dist/engagement/social-transfers/service/social-transfers.service.d.ts +72 -0
  143. package/dist/engagement/social-transfers/service/social-transfers.service.d.ts.map +1 -0
  144. package/dist/engagement/social-transfers/service/social-transfers.service.js +726 -0
  145. package/dist/engagement/social-transfers/service/social-transfers.service.js.map +1 -0
  146. package/dist/iam/plugin.d.ts +2 -0
  147. package/dist/iam/plugin.d.ts.map +1 -1
  148. package/dist/iam/service/iam.service.d.ts +1 -1
  149. package/dist/pam/identity/__tests__/identity.service.int.test.js +47 -2
  150. package/dist/pam/identity/__tests__/identity.service.int.test.js.map +1 -1
  151. package/dist/pam/identity/__tests__/phone-login.service.int.test.js +23 -2
  152. package/dist/pam/identity/__tests__/phone-login.service.int.test.js.map +1 -1
  153. package/dist/pam/identity/plugin.d.ts +2 -0
  154. package/dist/pam/identity/plugin.d.ts.map +1 -1
  155. package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
  156. package/dist/pam/identity/service/identity.service.js +29 -1
  157. package/dist/pam/identity/service/identity.service.js.map +1 -1
  158. package/dist/pam/identity/service/phone-login.service.d.ts.map +1 -1
  159. package/dist/pam/identity/service/phone-login.service.js +23 -0
  160. package/dist/pam/identity/service/phone-login.service.js.map +1 -1
  161. package/dist/pam/player-management/__tests__/player.router.int.test.js +27 -4
  162. package/dist/pam/player-management/__tests__/player.router.int.test.js.map +1 -1
  163. package/dist/pam/player-management/__tests__/player.service.int.test.js +159 -6
  164. package/dist/pam/player-management/__tests__/player.service.int.test.js.map +1 -1
  165. package/dist/pam/player-management/contract/index.d.ts +39 -0
  166. package/dist/pam/player-management/contract/index.d.ts.map +1 -1
  167. package/dist/pam/player-management/contract/index.js +33 -1
  168. package/dist/pam/player-management/contract/index.js.map +1 -1
  169. package/dist/pam/player-management/plugin.d.ts +3 -1
  170. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  171. package/dist/pam/player-management/plugin.js +3 -3
  172. package/dist/pam/player-management/plugin.js.map +1 -1
  173. package/dist/pam/player-management/router/index.d.ts +21 -0
  174. package/dist/pam/player-management/router/index.d.ts.map +1 -1
  175. package/dist/pam/player-management/router/index.js +12 -2
  176. package/dist/pam/player-management/router/index.js.map +1 -1
  177. package/dist/pam/player-management/service/player.service.d.ts +10 -3
  178. package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
  179. package/dist/pam/player-management/service/player.service.js +66 -3
  180. package/dist/pam/player-management/service/player.service.js.map +1 -1
  181. package/dist/pam/player-note/__tests__/player-note.router.int.test.d.ts +2 -0
  182. package/dist/pam/player-note/__tests__/player-note.router.int.test.d.ts.map +1 -0
  183. package/dist/pam/player-note/__tests__/player-note.router.int.test.js +52 -0
  184. package/dist/pam/player-note/__tests__/player-note.router.int.test.js.map +1 -0
  185. package/dist/pam/player-note/plugin.d.ts +49 -46
  186. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  187. package/dist/pam/player-note/plugin.js +3 -1
  188. package/dist/pam/player-note/plugin.js.map +1 -1
  189. package/dist/pam/player-note/router/index.d.ts +2 -1
  190. package/dist/pam/player-note/router/index.d.ts.map +1 -1
  191. package/dist/pam/player-note/router/index.js +13 -2
  192. package/dist/pam/player-note/router/index.js.map +1 -1
  193. package/dist/pam/profile/plugin.d.ts +2 -0
  194. package/dist/pam/profile/plugin.d.ts.map +1 -1
  195. package/dist/pam/tag/plugin.d.ts +2 -0
  196. package/dist/pam/tag/plugin.d.ts.map +1 -1
  197. package/dist/server/auth/permissions.d.ts +24 -0
  198. package/dist/server/auth/permissions.d.ts.map +1 -1
  199. package/dist/server/auth/permissions.js +4 -0
  200. package/dist/server/auth/permissions.js.map +1 -1
  201. package/dist/server/runtime/core-token-catalog.d.ts +2 -0
  202. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  203. package/dist/server/runtime/core-token-catalog.js +3 -1
  204. package/dist/server/runtime/core-token-catalog.js.map +1 -1
  205. package/dist/wallet/__tests__/wallet.service.int.test.js +7 -0
  206. package/dist/wallet/__tests__/wallet.service.int.test.js.map +1 -1
  207. package/dist/wallet/plugin.d.ts +2 -0
  208. package/dist/wallet/plugin.d.ts.map +1 -1
  209. package/dist/wallet/router/index.js +1 -1
  210. package/dist/wallet/router/index.js.map +1 -1
  211. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  212. package/dist/wallet/service/wallet.service.js +16 -0
  213. package/dist/wallet/service/wallet.service.js.map +1 -1
  214. package/package.json +21 -1
@@ -1,12 +1,12 @@
1
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';
2
+ import { mock, makeDrizzle } from '../../../../testing/mock.js';
3
+ import { ChatCommandsService, CommandDisabledError, InsufficientBalanceError, NoOnlineUsersError, GiftNotFoundError, GiftAlreadyClaimedError, ChatRoomNotMemberError, } from '../chat-commands.service.js';
4
4
  const ACTOR_ID = '00000000-0000-0000-0000-000000000001';
5
5
  const CLAIMER_ID = '00000000-0000-0000-0000-000000000002';
6
6
  const ROOM_ID = '00000000-0000-0000-0000-000000000003';
7
7
  const MSG_ID = '00000000-0000-0000-0000-000000000004';
8
8
  const GIFT_ID = '00000000-0000-0000-0000-000000000005';
9
- const OTHER_ROOM_ID = '00000000-0000-0000-0000-000000000009';
9
+ const IDEMPOTENCY_KEY = '00000000-0000-0000-0000-0000000000aa';
10
10
  const ENABLED_ROW = {
11
11
  key: 'gift',
12
12
  enabled: true,
@@ -31,69 +31,54 @@ const SYSTEM_MSG = {
31
31
  },
32
32
  createdAt: new Date().toISOString(),
33
33
  };
34
- /** Gift row as returned from the DB (dates as Date objects, amount as string). */
35
- const GIFT_ROW = {
34
+ function makeDirectory() {
35
+ return mock({
36
+ findPlayerIds: vi.fn().mockResolvedValue([ACTOR_ID]),
37
+ lookupPlayers: vi
38
+ .fn()
39
+ .mockImplementation((ids) => Promise.resolve(ids.filter((id) => id === ACTOR_ID).map((id) => ({ userId: id, username: 'bob' })))),
40
+ });
41
+ }
42
+ function makeBlockWriter(excluded = []) {
43
+ return mock({
44
+ blockUser: vi.fn().mockResolvedValue(undefined),
45
+ ignoreUser: vi.fn().mockResolvedValue(undefined),
46
+ getExcludedUserIds: vi.fn().mockResolvedValue(excluded),
47
+ });
48
+ }
49
+ const GIFT_STATE = {
36
50
  id: GIFT_ID,
37
- messageId: MSG_ID,
38
51
  senderId: ACTOR_ID,
39
52
  senderUsername: 'bob',
40
53
  amount: '10.00000000',
41
54
  currency: 'USD',
42
- roomId: ROOM_ID,
43
55
  claimedBy: null,
44
56
  claimedByUsername: null,
45
57
  claimedAt: null,
46
- createdAt: new Date(),
58
+ createdAt: new Date().toISOString(),
47
59
  };
48
- function makeWriter() {
60
+ function makeGiftCommands(overrides = {}) {
49
61
  return mock({
50
- postSystemMessage: vi.fn().mockResolvedValue(SYSTEM_MSG),
62
+ sendGift: vi.fn().mockResolvedValue({ ok: true, message: SYSTEM_MSG }),
63
+ claimGift: vi.fn().mockResolvedValue({
64
+ ok: true,
65
+ claimedBy: CLAIMER_ID,
66
+ claimedByUsername: 'alice',
67
+ claimedAt: new Date().toISOString(),
68
+ }),
69
+ getGift: vi.fn().mockResolvedValue({ ok: true, gift: GIFT_STATE }),
70
+ ...overrides,
51
71
  });
52
72
  }
53
- function makeWallet(ok = true) {
73
+ function makeRainCommands(overrides = {}) {
54
74
  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' }),
75
+ sendRain: vi.fn().mockResolvedValue({ ok: true, message: SYSTEM_MSG }),
76
+ ...overrides,
61
77
  });
62
78
  }
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
- ];
79
+ function makeTransport(onlineIds = [CLAIMER_ID]) {
89
80
  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
- }),
81
+ getOnlineUserIds: vi.fn().mockResolvedValue(onlineIds),
97
82
  });
98
83
  }
99
84
  function makeAudit() {
@@ -102,71 +87,22 @@ function makeAudit() {
102
87
  recordInTransaction: vi.fn().mockResolvedValue(undefined),
103
88
  });
104
89
  }
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
90
  function makeSvc(overrides = {}) {
148
91
  const drizzle = makeDrizzle({
149
92
  select: overrides.drizzleRows?.select ?? [],
150
93
  returning: overrides.drizzleRows?.returning ?? [],
151
- execute: overrides.drizzleRows?.execute,
152
94
  });
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());
95
+ return new ChatCommandsService(drizzle, overrides.directory ?? makeDirectory(), overrides.blockWriter ?? makeBlockWriter(), overrides.giftCommands ?? makeGiftCommands(), overrides.rainCommands ?? makeRainCommands(), overrides.transport ?? makeTransport(), overrides.audit ?? makeAudit());
154
96
  }
155
97
  describe('ChatCommandsService.listCommands', () => {
156
98
  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
- });
99
+ const svc = makeSvc({ drizzleRows: { select: [[ENABLED_ROW]] } });
161
100
  const result = await svc.listCommands();
162
101
  expect(result).toHaveLength(1);
163
102
  expect(result[0]?.key).toBe('gift');
164
103
  });
165
104
  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
- });
105
+ const svc = makeSvc({ drizzleRows: { select: [[ENABLED_ROW, DISABLED_ROW]] } });
170
106
  const result = await svc.listCommands(true);
171
107
  expect(result).toHaveLength(2);
172
108
  });
@@ -189,700 +125,228 @@ describe('ChatCommandsService.searchMentions', () => {
189
125
  });
190
126
  it('excludes ids the viewer has blocked or ignored', async () => {
191
127
  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
- });
128
+ const blockWriter = makeBlockWriter([ACTOR_ID]);
197
129
  const svc = makeSvc({ directory, blockWriter });
198
130
  const result = await svc.searchMentions('bo', 5, CLAIMER_ID);
199
131
  expect(blockWriter.getExcludedUserIds).toHaveBeenCalledWith(CLAIMER_ID);
200
132
  expect(result).toEqual([]);
201
133
  });
202
134
  });
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();
135
+ describe('ChatCommandsService.postGift (delegates to GIFT_COMMANDS)', () => {
136
+ it('returns the message from the port on success', async () => {
137
+ const giftCommands = makeGiftCommands();
138
+ const svc = makeSvc({ giftCommands });
139
+ const result = await svc.postGift({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
140
+ expect(giftCommands.sendGift).toHaveBeenCalledWith({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
141
+ expect(result).toEqual(SYSTEM_MSG);
272
142
  });
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),
143
+ it('maps a "disabled" port result to CommandDisabledError', async () => {
144
+ const giftCommands = makeGiftCommands({
145
+ sendGift: vi
146
+ .fn()
147
+ .mockResolvedValue({ ok: false, reason: 'disabled' }),
295
148
  });
296
- await expect(svc.executeCommand({ type: 'gift', amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID)).rejects.toThrow(InsufficientBalanceError);
149
+ const svc = makeSvc({ giftCommands });
150
+ await expect(svc.postGift({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID)).rejects.toThrow(CommandDisabledError);
297
151
  });
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,
152
+ it('maps an "insufficient_balance" port result to InsufficientBalanceError', async () => {
153
+ const giftCommands = makeGiftCommands({
154
+ sendGift: vi
155
+ .fn()
156
+ .mockResolvedValue({ ok: false, reason: 'insufficient_balance' }),
307
157
  });
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);
158
+ const svc = makeSvc({ giftCommands });
159
+ await expect(svc.postGift({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID)).rejects.toThrow(InsufficientBalanceError);
319
160
  });
320
- it('enforces minAmount from config', async () => {
321
- const svc = makeSvc({
322
- drizzleRows: {
323
- select: [[{ ...ENABLED_ROW, config: { minAmount: '5.00000000' } }]],
324
- },
161
+ it('maps a "room_not_member" port result to ChatRoomNotMemberError', async () => {
162
+ const giftCommands = makeGiftCommands({
163
+ sendGift: vi
164
+ .fn()
165
+ .mockResolvedValue({ ok: false, reason: 'room_not_member' }),
325
166
  });
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);
167
+ const svc = makeSvc({ giftCommands });
168
+ await expect(svc.postGift({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID)).rejects.toThrow(ChatRoomNotMemberError);
332
169
  });
333
170
  });
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
- });
171
+ describe('ChatCommandsService.claimGift (delegates to GIFT_COMMANDS)', () => {
172
+ it('returns the claim info from the port on success', async () => {
173
+ const giftCommands = makeGiftCommands();
174
+ const svc = makeSvc({ giftCommands });
446
175
  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' }));
176
+ expect(giftCommands.claimGift).toHaveBeenCalledWith(GIFT_ID, CLAIMER_ID);
449
177
  expect(result.claimedBy).toBe(CLAIMER_ID);
450
178
  expect(result.claimedByUsername).toBe('alice');
451
- expect(result.claimedAt).toEqual(expect.any(String));
452
179
  });
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
- },
180
+ it('maps a "gift_not_found" port result to GiftNotFoundError', async () => {
181
+ const giftCommands = makeGiftCommands({
182
+ claimGift: vi
183
+ .fn()
184
+ .mockResolvedValue({ ok: false, reason: 'gift_not_found' }),
459
185
  });
460
- await expect(svc.claimGift(GIFT_ID, ACTOR_ID)).rejects.toThrow(GiftSelfClaimError);
186
+ const svc = makeSvc({ giftCommands });
187
+ await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftNotFoundError);
461
188
  });
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
- },
189
+ it('maps an "already_claimed" port result to GiftAlreadyClaimedError', async () => {
190
+ const giftCommands = makeGiftCommands({
191
+ claimGift: vi
192
+ .fn()
193
+ .mockResolvedValue({ ok: false, reason: 'already_claimed' }),
468
194
  });
195
+ const svc = makeSvc({ giftCommands });
469
196
  await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftAlreadyClaimedError);
470
197
  });
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]] },
198
+ it('maps a "room_not_member" port result to ChatRoomNotMemberError using the port\'s roomId, not the giftId', async () => {
199
+ const giftCommands = makeGiftCommands({
200
+ claimGift: vi.fn().mockResolvedValue({
201
+ ok: false,
202
+ reason: 'room_not_member',
203
+ roomId: ROOM_ID,
204
+ }),
486
205
  });
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
- }));
206
+ const svc = makeSvc({ giftCommands });
207
+ await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(new ChatRoomNotMemberError(ROOM_ID));
503
208
  });
504
209
  });
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,
210
+ describe('ChatCommandsService.getGift (delegates to GIFT_COMMANDS)', () => {
211
+ it('returns the gift state from the port on success', async () => {
212
+ const giftCommands = makeGiftCommands();
213
+ const svc = makeSvc({ giftCommands });
214
+ const result = await svc.getGift(GIFT_ID, CLAIMER_ID);
215
+ expect(giftCommands.getGift).toHaveBeenCalledWith(GIFT_ID, CLAIMER_ID);
216
+ expect(result).toEqual(GIFT_STATE);
217
+ });
218
+ it('maps a "gift_not_found" port result to GiftNotFoundError', async () => {
219
+ const giftCommands = makeGiftCommands({
220
+ getGift: vi
221
+ .fn()
222
+ .mockResolvedValue({ ok: false, reason: 'gift_not_found' }),
223
+ });
224
+ const svc = makeSvc({ giftCommands });
225
+ await expect(svc.getGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftNotFoundError);
226
+ });
227
+ it('maps a "room_not_member" port result to ChatRoomNotMemberError using the port\'s roomId', async () => {
228
+ const giftCommands = makeGiftCommands({
229
+ getGift: vi.fn().mockResolvedValue({
230
+ ok: false,
231
+ reason: 'room_not_member',
532
232
  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
233
  }),
544
234
  });
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();
235
+ const svc = makeSvc({ giftCommands });
236
+ await expect(svc.getGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(new ChatRoomNotMemberError(ROOM_ID));
553
237
  });
554
- it('distributes to online recipients excluding the actor', async () => {
238
+ });
239
+ describe('ChatCommandsService.postRain (resolves presence, delegates to RAIN_COMMANDS)', () => {
240
+ it('resolves online user ids via CHAT_REALTIME_TRANSPORT and passes them to the port', async () => {
555
241
  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',
242
+ const transport = makeTransport([ACTOR_ID, CLAIMER_ID, RECIPIENT_2]);
243
+ const rainCommands = makeRainCommands();
244
+ const svc = makeSvc({ transport, rainCommands });
245
+ const result = await svc.postRain({
568
246
  amount: '10.00000000',
569
247
  recipientCount: 2,
570
248
  roomId: ROOM_ID,
571
249
  idempotencyKey: IDEMPOTENCY_KEY,
572
250
  }, 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',
251
+ expect(transport.getOnlineUserIds).toHaveBeenCalledWith(`chat:room:${ROOM_ID}`);
252
+ expect(rainCommands.sendRain).toHaveBeenCalledWith({
769
253
  amount: '10.00000000',
254
+ recipientCount: 2,
770
255
  roomId: ROOM_ID,
771
256
  idempotencyKey: IDEMPOTENCY_KEY,
257
+ onlineUserIds: [ACTOR_ID, CLAIMER_ID, RECIPIENT_2],
772
258
  }, 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);
259
+ expect(result).toEqual(SYSTEM_MSG);
785
260
  });
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]] },
261
+ it('maps a "no_online_users" port result to NoOnlineUsersError', async () => {
262
+ const rainCommands = makeRainCommands({
263
+ sendRain: vi
264
+ .fn()
265
+ .mockResolvedValue({ ok: false, reason: 'no_online_users' }),
790
266
  });
791
- await expect(svc.executeCommand({
792
- type: 'donate',
793
- targetUsername: 'bob',
267
+ const svc = makeSvc({ rainCommands, transport: makeTransport([ACTOR_ID]) });
268
+ await expect(svc.postRain({
794
269
  amount: '10.00000000',
270
+ recipientCount: 2,
795
271
  roomId: ROOM_ID,
796
272
  idempotencyKey: IDEMPOTENCY_KEY,
797
- }, ACTOR_ID)).rejects.toThrow(DonateSelfError);
273
+ }, ACTOR_ID)).rejects.toThrow(NoOnlineUsersError);
798
274
  });
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),
275
+ it('maps a "disabled" port result to CommandDisabledError', async () => {
276
+ const rainCommands = makeRainCommands({
277
+ sendRain: vi
278
+ .fn()
279
+ .mockResolvedValue({ ok: false, reason: 'disabled' }),
804
280
  });
805
- const svc = makeSvc({
806
- drizzleRows: { select: [[DONATE_ROW]] },
807
- directory,
808
- });
809
- await expect(svc.executeCommand({
810
- type: 'donate',
811
- targetUsername: 'ghost',
281
+ const svc = makeSvc({ rainCommands });
282
+ await expect(svc.postRain({
812
283
  amount: '10.00000000',
284
+ recipientCount: 2,
813
285
  roomId: ROOM_ID,
814
286
  idempotencyKey: IDEMPOTENCY_KEY,
815
- }, ACTOR_ID)).rejects.toThrow(ChatPlayerNotFoundError);
287
+ }, ACTOR_ID)).rejects.toThrow(CommandDisabledError);
816
288
  });
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(),
289
+ it('maps a "room_not_member" port result to ChatRoomNotMemberError', async () => {
290
+ const rainCommands = makeRainCommands({
291
+ sendRain: vi
292
+ .fn()
293
+ .mockResolvedValue({ ok: false, reason: 'room_not_member' }),
825
294
  });
826
- await expect(svc.executeCommand({
827
- type: 'donate',
828
- targetUsername: 'alice',
295
+ const svc = makeSvc({ rainCommands });
296
+ await expect(svc.postRain({
829
297
  amount: '10.00000000',
298
+ recipientCount: 2,
830
299
  roomId: ROOM_ID,
831
300
  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);
301
+ }, ACTOR_ID)).rejects.toThrow(ChatRoomNotMemberError);
848
302
  });
849
303
  });
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();
304
+ describe('ChatCommandsService.adminListCommands', () => {
305
+ it('returns disabled commands too, unlike listCommands', async () => {
855
306
  const svc = makeSvc({
856
- drizzleRows: { select: [[BLOCK_ROW]] },
857
- directory: makeRecipientDirectory(),
858
- blockWriter,
307
+ drizzleRows: { select: [[ENABLED_ROW, DISABLED_ROW], [{ n: 2 }]] },
859
308
  });
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,
309
+ const result = await svc.adminListCommands({
310
+ page: 1,
311
+ limit: 10,
312
+ sortBy: 'key',
313
+ sortOrder: 'asc',
870
314
  });
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();
315
+ expect(result.items).toHaveLength(2);
316
+ expect(result.total).toBe(2);
317
+ expect(result.items.map((c) => c.enabled)).toEqual([true, false]);
874
318
  });
875
- it('throws SelfModerationActionError on self-block', async () => {
876
- const svc = makeSvc({
877
- drizzleRows: { select: [[BLOCK_ROW]] },
319
+ });
320
+ describe('ChatCommandsService.adminUpdateCommand', () => {
321
+ it('creates a new config row when none exists', async () => {
322
+ const row = { ...ENABLED_ROW, key: 'donate', label: 'Donate' };
323
+ const svc = makeSvc({ drizzleRows: { returning: [[row]] } });
324
+ const result = await svc.adminUpdateCommand({ key: 'donate', enabled: true }, ACTOR_ID);
325
+ expect(result).toMatchObject({ key: 'donate', label: 'Donate', enabled: true });
326
+ });
327
+ it('updates an existing config row', async () => {
328
+ const row = { ...ENABLED_ROW, enabled: false, config: { maxAmount: '100.00000000' } };
329
+ const svc = makeSvc({ drizzleRows: { returning: [[row]] } });
330
+ const result = await svc.adminUpdateCommand({ key: 'gift', enabled: false, config: { maxAmount: '100.00000000' } }, ACTOR_ID);
331
+ expect(result).toMatchObject({ key: 'gift', enabled: false });
332
+ });
333
+ it('records an audit entry describing the change', async () => {
334
+ const audit = makeAudit();
335
+ const svc = makeSvc({ audit, drizzleRows: { returning: [[ENABLED_ROW]] } });
336
+ await svc.adminUpdateCommand({ key: 'gift', enabled: true, config: { maxAmount: '50.00000000' } }, ACTOR_ID);
337
+ expect(audit.record).toHaveBeenCalledWith({
338
+ actorId: ACTOR_ID,
339
+ actorType: 'admin',
340
+ action: 'chat.command.updated',
341
+ resourceType: 'chat_command',
342
+ resourceId: 'gift',
343
+ before: null,
344
+ after: { enabled: true, config: { maxAmount: '50.00000000' } },
878
345
  });
879
- await expect(svc.executeCommand({ type: 'block', targetUsername: 'bob', roomId: ROOM_ID }, ACTOR_ID)).rejects.toThrow(SelfModerationActionError);
880
346
  });
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);
347
+ it('throws CommandDisabledError when the insert/update returns no row', async () => {
348
+ const svc = makeSvc({ drizzleRows: { returning: [[]] } });
349
+ await expect(svc.adminUpdateCommand({ key: 'gift', enabled: true }, ACTOR_ID)).rejects.toThrow(CommandDisabledError);
886
350
  });
887
351
  });
888
352
  //# sourceMappingURL=chat-commands.service.test.js.map