@openora/core 0.4.1-canary.60 → 0.4.1-canary.63

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