@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
@@ -0,0 +1,757 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { mock, makeDrizzle, makeEventBus } from '../../../../testing/mock.js';
3
+ import { SocialTransfersService, InsufficientBalanceError, BelowMinimumError, DonateSelfError, ChatPlayerNotFoundError, fingerprintCommand, } from '../social-transfers.service.js';
4
+ const ACTOR_ID = '00000000-0000-0000-0000-000000000001';
5
+ const CLAIMER_ID = '00000000-0000-0000-0000-000000000002';
6
+ const RECIPIENT_2 = '00000000-0000-0000-0000-000000000006';
7
+ const ROOM_ID = '00000000-0000-0000-0000-000000000003';
8
+ const MSG_ID = '00000000-0000-0000-0000-000000000004';
9
+ const GIFT_ID = '00000000-0000-0000-0000-000000000005';
10
+ const OTHER_ROOM_ID = '00000000-0000-0000-0000-000000000009';
11
+ const ENABLED_ROW = {
12
+ key: 'gift',
13
+ enabled: true,
14
+ label: 'Gift',
15
+ description: null,
16
+ config: null,
17
+ updatedAt: new Date(),
18
+ };
19
+ const DISABLED_ROW = { ...ENABLED_ROW, enabled: false };
20
+ const SYSTEM_MSG = {
21
+ id: MSG_ID,
22
+ roomId: ROOM_ID,
23
+ actorId: ACTOR_ID,
24
+ userId: ACTOR_ID,
25
+ username: 'system',
26
+ content: '',
27
+ type: 'system',
28
+ isDeleted: false,
29
+ metadata: {
30
+ command: 'gift',
31
+ giftId: GIFT_ID,
32
+ senderId: ACTOR_ID,
33
+ senderUsername: 'bob',
34
+ amount: '10.00000000',
35
+ currency: 'USD',
36
+ status: 'available',
37
+ claimedBy: null,
38
+ claimedByUsername: null,
39
+ claimedAt: null,
40
+ },
41
+ createdAt: new Date().toISOString(),
42
+ };
43
+ /** Gift row as returned from the DB (dates as Date objects, amount as string). */
44
+ const GIFT_ROW = {
45
+ id: GIFT_ID,
46
+ messageId: MSG_ID,
47
+ senderId: ACTOR_ID,
48
+ senderUsername: 'bob',
49
+ amount: '10.00000000',
50
+ currency: 'USD',
51
+ roomId: ROOM_ID,
52
+ claimedBy: null,
53
+ claimedByUsername: null,
54
+ claimedAt: null,
55
+ createdAt: new Date(),
56
+ };
57
+ function makeWriter() {
58
+ return mock({
59
+ postSystemMessage: vi.fn().mockResolvedValue(SYSTEM_MSG),
60
+ updateSystemMessage: vi.fn().mockResolvedValue(SYSTEM_MSG),
61
+ });
62
+ }
63
+ function makeWallet(ok = true) {
64
+ return mock({
65
+ debit: vi
66
+ .fn()
67
+ .mockResolvedValue(ok
68
+ ? { ok: true, newBalance: '90.00000000', currency: 'USD' }
69
+ : { ok: false, available: '5.00000000' }),
70
+ credit: vi.fn().mockResolvedValue({ ok: true, newBalance: '110.00000000' }),
71
+ });
72
+ }
73
+ const DIRECTORY_CREATED_AT = new Date('2026-01-01T00:00:00.000Z');
74
+ function makeDirectory(senderUsername = 'bob', claimerUsername = 'alice', extraUserIds = []) {
75
+ const all = [
76
+ {
77
+ userId: ACTOR_ID,
78
+ username: senderUsername,
79
+ email: 'bob@example.com',
80
+ kycStatus: null,
81
+ language: null,
82
+ avatarUrl: null,
83
+ createdAt: DIRECTORY_CREATED_AT,
84
+ level: 3,
85
+ currency: 'USD',
86
+ },
87
+ {
88
+ userId: CLAIMER_ID,
89
+ username: claimerUsername,
90
+ email: 'alice@example.com',
91
+ kycStatus: null,
92
+ language: null,
93
+ avatarUrl: null,
94
+ createdAt: DIRECTORY_CREATED_AT,
95
+ level: 1,
96
+ currency: 'USD',
97
+ },
98
+ {
99
+ userId: RECIPIENT_2,
100
+ username: 'charlie',
101
+ email: 'charlie@example.com',
102
+ kycStatus: null,
103
+ language: null,
104
+ avatarUrl: null,
105
+ createdAt: DIRECTORY_CREATED_AT,
106
+ level: 1,
107
+ currency: 'USD',
108
+ },
109
+ ...extraUserIds.map((userId, index) => ({
110
+ userId,
111
+ username: `player${index}`,
112
+ email: `player${index}@example.com`,
113
+ kycStatus: null,
114
+ language: null,
115
+ avatarUrl: null,
116
+ createdAt: DIRECTORY_CREATED_AT,
117
+ level: 1,
118
+ currency: 'USD',
119
+ })),
120
+ ];
121
+ return mock({
122
+ findPlayerIds: vi.fn().mockResolvedValue([ACTOR_ID]),
123
+ lookupPlayers: vi.fn().mockImplementation((ids) => {
124
+ return Promise.resolve(all.filter((p) => ids.includes(p.userId)));
125
+ }),
126
+ getPlayerByUsername: vi.fn().mockImplementation((username) => {
127
+ return Promise.resolve(all.find((p) => p.username.toLowerCase() === username.toLowerCase()) ?? null);
128
+ }),
129
+ });
130
+ }
131
+ function makeAudit() {
132
+ return mock({
133
+ record: vi.fn().mockResolvedValue(undefined),
134
+ recordInTransaction: vi.fn().mockResolvedValue(undefined),
135
+ });
136
+ }
137
+ function makeTransport(onlineIds = [CLAIMER_ID]) {
138
+ return mock({
139
+ getOnlineUserIds: vi.fn().mockResolvedValue(onlineIds),
140
+ publish: vi.fn().mockResolvedValue(undefined),
141
+ });
142
+ }
143
+ function makeRoomAccess() {
144
+ return mock({ verifyRoomAccess: vi.fn().mockResolvedValue(undefined) });
145
+ }
146
+ function makeCache(initial = {}) {
147
+ const values = new Map(Object.entries(initial));
148
+ return {
149
+ get: async (key) => values.get(key),
150
+ set: vi.fn(async (key, value) => {
151
+ values.set(key, value);
152
+ }),
153
+ setIfAbsent: vi.fn(async (key, value) => {
154
+ if (values.has(key)) {
155
+ return false;
156
+ }
157
+ values.set(key, value);
158
+ return true;
159
+ }),
160
+ delete: vi.fn(async (key) => {
161
+ for (const item of Array.isArray(key) ? key : [key]) {
162
+ values.delete(item);
163
+ }
164
+ }),
165
+ };
166
+ }
167
+ function makeSvc(overrides = {}) {
168
+ const drizzle = makeDrizzle({
169
+ select: overrides.drizzleRows?.select ?? [],
170
+ returning: overrides.drizzleRows?.returning ?? [],
171
+ execute: overrides.drizzleRows?.execute,
172
+ });
173
+ return new SocialTransfersService(drizzle, overrides.writer ?? makeWriter(), overrides.wallet ?? makeWallet(), overrides.directory ?? makeDirectory(), overrides.audit ?? makeAudit(), overrides.transport ?? makeTransport(), mock(makeEventBus()), overrides.roomAccess ?? makeRoomAccess(), overrides.cache ?? makeCache());
174
+ }
175
+ const IDEMPOTENCY_KEY = '00000000-0000-0000-0000-0000000000aa';
176
+ const IDEMPOTENCY_CACHE_KEY = `chat-command:idempotency:${ACTOR_ID}:gift:${IDEMPOTENCY_KEY}`;
177
+ const RAIN_IDEMPOTENCY_CACHE_KEY = `chat-command:idempotency:${ACTOR_ID}:rain:${IDEMPOTENCY_KEY}`;
178
+ const IDEMPOTENCY_ROW_ID = '00000000-0000-0000-0000-0000000000bb';
179
+ describe('SocialTransfersService.sendGift (GIFT_COMMANDS port)', () => {
180
+ it('returns { ok: false, reason: "disabled" } when the command row is missing', async () => {
181
+ const svc = makeSvc({ drizzleRows: { select: [[]] } });
182
+ const result = await svc.sendGift({ amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
183
+ expect(result).toEqual({ ok: false, reason: 'disabled' });
184
+ });
185
+ it('returns { ok: false, reason: "disabled" } when the command is disabled', async () => {
186
+ const svc = makeSvc({ drizzleRows: { select: [[DISABLED_ROW]] } });
187
+ const result = await svc.sendGift({ amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
188
+ expect(result).toEqual({ ok: false, reason: 'disabled' });
189
+ });
190
+ it('returns { ok: false, reason: "player_not_found" } when the sender is missing', async () => {
191
+ const directory = mock({
192
+ lookupPlayers: vi.fn().mockResolvedValue([]),
193
+ });
194
+ const svc = makeSvc({
195
+ drizzleRows: { select: [[ENABLED_ROW]] },
196
+ directory,
197
+ });
198
+ const result = await svc.sendGift({ amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
199
+ expect(result).toEqual({ ok: false, reason: 'player_not_found', playerId: ACTOR_ID });
200
+ });
201
+ it('returns { ok: false, reason: "insufficient_balance" } when the wallet debit fails', async () => {
202
+ const svc = makeSvc({
203
+ drizzleRows: {
204
+ select: [[ENABLED_ROW]],
205
+ returning: [[{ id: IDEMPOTENCY_ROW_ID }]],
206
+ },
207
+ wallet: makeWallet(false),
208
+ });
209
+ const result = await svc.sendGift({ amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
210
+ expect(result).toEqual({ ok: false, reason: 'insufficient_balance' });
211
+ });
212
+ it('posts a system message with gift metadata and returns { ok: true } on success', async () => {
213
+ const writer = makeWriter();
214
+ const svc = makeSvc({
215
+ drizzleRows: {
216
+ select: [[ENABLED_ROW]],
217
+ returning: [[{ ...GIFT_ROW }]],
218
+ },
219
+ writer,
220
+ });
221
+ const result = await svc.sendGift({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
222
+ expect(writer.postSystemMessage).toHaveBeenCalledOnce();
223
+ expect(writer.postSystemMessage).toHaveBeenCalledWith(expect.objectContaining({
224
+ actorId: ACTOR_ID,
225
+ metadata: expect.objectContaining({
226
+ command: 'gift',
227
+ giftId: GIFT_ID,
228
+ senderId: ACTOR_ID,
229
+ senderUsername: 'bob',
230
+ }),
231
+ }));
232
+ expect(result).toEqual({ ok: true, message: SYSTEM_MSG });
233
+ });
234
+ it('returns { ok: false, reason: "below_minimum" } when amount is under config minAmount', async () => {
235
+ const svc = makeSvc({
236
+ drizzleRows: { select: [[{ ...ENABLED_ROW, config: { minAmount: '5.00000000' } }]] },
237
+ });
238
+ const result = await svc.sendGift({ amount: '1.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
239
+ expect(result).toEqual({ ok: false, reason: 'below_minimum' });
240
+ });
241
+ });
242
+ describe('SocialTransfersService.sendGift idempotency', () => {
243
+ it('inserts the idempotency guard row and debits once when a fresh key is supplied', async () => {
244
+ const wallet = makeWallet();
245
+ const svc = makeSvc({
246
+ drizzleRows: {
247
+ select: [[ENABLED_ROW], []],
248
+ returning: [[{ id: IDEMPOTENCY_ROW_ID }], [{ ...GIFT_ROW }]],
249
+ },
250
+ wallet,
251
+ });
252
+ const result = await svc.sendGift({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
253
+ expect(wallet.debit).toHaveBeenCalledOnce();
254
+ expect(result).toEqual({ ok: true, message: SYSTEM_MSG });
255
+ });
256
+ it('replays the stored result without debiting the wallet again', async () => {
257
+ const wallet = makeWallet();
258
+ const giftInput = {
259
+ type: 'gift',
260
+ amount: '10.00000000',
261
+ roomId: ROOM_ID,
262
+ idempotencyKey: IDEMPOTENCY_KEY,
263
+ };
264
+ const svc = makeSvc({
265
+ drizzleRows: { select: [[ENABLED_ROW]] },
266
+ cache: makeCache({
267
+ [IDEMPOTENCY_CACHE_KEY]: { fingerprint: fingerprintCommand(giftInput), result: SYSTEM_MSG },
268
+ }),
269
+ wallet,
270
+ });
271
+ const result = await svc.sendGift({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
272
+ expect(wallet.debit).not.toHaveBeenCalled();
273
+ expect(result).toEqual({ ok: true, message: SYSTEM_MSG });
274
+ });
275
+ it('returns { ok: false, reason: "idempotency_key_reuse" } when the same key is reused with a different amount', async () => {
276
+ const svc = makeSvc({
277
+ drizzleRows: { select: [[ENABLED_ROW]] },
278
+ cache: makeCache({
279
+ [IDEMPOTENCY_CACHE_KEY]: {
280
+ fingerprint: fingerprintCommand({
281
+ type: 'gift',
282
+ amount: '5.00000000',
283
+ roomId: ROOM_ID,
284
+ idempotencyKey: IDEMPOTENCY_KEY,
285
+ }),
286
+ result: SYSTEM_MSG,
287
+ },
288
+ }),
289
+ });
290
+ const result = await svc.sendGift({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
291
+ expect(result).toEqual({ ok: false, reason: 'idempotency_key_reuse' });
292
+ });
293
+ it('returns { ok: false, reason: "idempotency_key_reuse" } when the same key+amount is reused for a different room', async () => {
294
+ const svc = makeSvc({
295
+ drizzleRows: { select: [[ENABLED_ROW]] },
296
+ cache: makeCache({
297
+ [IDEMPOTENCY_CACHE_KEY]: {
298
+ fingerprint: fingerprintCommand({
299
+ type: 'gift',
300
+ amount: '10.00000000',
301
+ roomId: OTHER_ROOM_ID,
302
+ idempotencyKey: IDEMPOTENCY_KEY,
303
+ }),
304
+ result: SYSTEM_MSG,
305
+ },
306
+ }),
307
+ });
308
+ const result = await svc.sendGift({ amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID);
309
+ expect(result).toEqual({ ok: false, reason: 'idempotency_key_reuse' });
310
+ });
311
+ });
312
+ describe('SocialTransfersService.claimGift', () => {
313
+ it('credits the claimer and returns { ok: true } on happy path', async () => {
314
+ const wallet = makeWallet();
315
+ const writer = makeWriter();
316
+ const svc = makeSvc({
317
+ drizzleRows: {
318
+ select: [[GIFT_ROW]],
319
+ returning: [
320
+ [
321
+ {
322
+ ...GIFT_ROW,
323
+ claimedBy: CLAIMER_ID,
324
+ claimedByUsername: 'alice',
325
+ claimedAt: new Date(),
326
+ },
327
+ ],
328
+ ],
329
+ },
330
+ wallet,
331
+ writer,
332
+ });
333
+ const result = await svc.claimGift(GIFT_ID, CLAIMER_ID);
334
+ expect(wallet.credit).toHaveBeenCalledOnce();
335
+ expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ userId: CLAIMER_ID, type: 'gift' }));
336
+ expect(result.ok).toBe(true);
337
+ if (result.ok) {
338
+ expect(result.claimedBy).toBe(CLAIMER_ID);
339
+ expect(result.claimedByUsername).toBe('alice');
340
+ expect(result.claimedAt).toEqual(expect.any(String));
341
+ }
342
+ expect(writer.updateSystemMessage).toHaveBeenCalledWith(expect.objectContaining({
343
+ messageId: MSG_ID,
344
+ metadata: expect.objectContaining({
345
+ command: 'gift',
346
+ status: 'claimed',
347
+ claimedBy: CLAIMER_ID,
348
+ claimedByUsername: 'alice',
349
+ }),
350
+ }));
351
+ });
352
+ it('returns { ok: false, reason: "self_claim" } when the sender tries to claim their own gift', async () => {
353
+ const svc = makeSvc({
354
+ drizzleRows: { select: [[GIFT_ROW]], returning: [] }, // senderId === ACTOR_ID
355
+ });
356
+ const result = await svc.claimGift(GIFT_ID, ACTOR_ID);
357
+ expect(result).toEqual({ ok: false, reason: 'self_claim' });
358
+ });
359
+ it('returns { ok: false, reason: "already_claimed" } when the update returns no rows (race lost)', async () => {
360
+ const svc = makeSvc({
361
+ drizzleRows: { select: [[GIFT_ROW]], returning: [[]] }, // empty = already claimed
362
+ });
363
+ const result = await svc.claimGift(GIFT_ID, CLAIMER_ID);
364
+ expect(result).toEqual({ ok: false, reason: 'already_claimed' });
365
+ });
366
+ it('returns { ok: false, reason: "gift_not_found" } when the gift does not exist', async () => {
367
+ const svc = makeSvc({
368
+ drizzleRows: { select: [[]], returning: [] }, // no gift row
369
+ });
370
+ const result = await svc.claimGift(GIFT_ID, CLAIMER_ID);
371
+ expect(result).toEqual({ ok: false, reason: 'gift_not_found' });
372
+ });
373
+ it('returns { ok: false, reason: "room_not_member", roomId: <the gift\'s room> } when the claimer is not a room member', async () => {
374
+ const notMember = Object.assign(new Error('not a member'), { name: 'ChatRoomNotMemberError' });
375
+ const roomAccess = mock({
376
+ verifyRoomAccess: vi.fn().mockRejectedValue(notMember),
377
+ });
378
+ const svc = makeSvc({
379
+ drizzleRows: { select: [[GIFT_ROW]], returning: [] },
380
+ roomAccess,
381
+ });
382
+ const result = await svc.claimGift(GIFT_ID, CLAIMER_ID);
383
+ expect(result).toEqual({ ok: false, reason: 'room_not_member', roomId: GIFT_ROW.roomId });
384
+ });
385
+ });
386
+ describe('SocialTransfersService.getGift', () => {
387
+ it('returns { ok: true, gift } for a gift in a room the viewer is a member of', async () => {
388
+ const svc = makeSvc({ drizzleRows: { select: [[GIFT_ROW]] } });
389
+ const result = await svc.getGift(GIFT_ID, CLAIMER_ID);
390
+ expect(result).toEqual({
391
+ ok: true,
392
+ gift: {
393
+ id: GIFT_ROW.id,
394
+ senderId: GIFT_ROW.senderId,
395
+ senderUsername: GIFT_ROW.senderUsername,
396
+ amount: GIFT_ROW.amount,
397
+ currency: GIFT_ROW.currency,
398
+ claimedBy: null,
399
+ claimedByUsername: null,
400
+ claimedAt: null,
401
+ createdAt: GIFT_ROW.createdAt.toISOString(),
402
+ },
403
+ });
404
+ });
405
+ it('returns { ok: false, reason: "gift_not_found" } when the gift does not exist', async () => {
406
+ const svc = makeSvc({ drizzleRows: { select: [[]] } });
407
+ const result = await svc.getGift(GIFT_ID, CLAIMER_ID);
408
+ expect(result).toEqual({ ok: false, reason: 'gift_not_found' });
409
+ });
410
+ it('returns { ok: false, reason: "room_not_member", roomId: <the gift\'s room> } when the viewer is not a room member', async () => {
411
+ const notMember = Object.assign(new Error('not a member'), { name: 'ChatRoomNotMemberError' });
412
+ const roomAccess = mock({
413
+ verifyRoomAccess: vi.fn().mockRejectedValue(notMember),
414
+ });
415
+ const svc = makeSvc({
416
+ drizzleRows: { select: [[GIFT_ROW]] },
417
+ roomAccess,
418
+ });
419
+ const result = await svc.getGift(GIFT_ID, CLAIMER_ID);
420
+ expect(result).toEqual({ ok: false, reason: 'room_not_member', roomId: GIFT_ROW.roomId });
421
+ });
422
+ });
423
+ describe('SocialTransfersService.sendRain (RAIN_COMMANDS port)', () => {
424
+ const RAIN_ROW = { ...ENABLED_ROW, key: 'rain', label: 'Rain' };
425
+ it('returns { ok: false, reason: "player_not_found" } when a recipient is missing', async () => {
426
+ const directory = mock({
427
+ lookupPlayers: vi
428
+ .fn()
429
+ .mockImplementation((ids) => Promise.resolve(ids.includes(ACTOR_ID) ? [{ userId: ACTOR_ID, username: 'bob' }] : [])),
430
+ });
431
+ const svc = makeSvc({
432
+ drizzleRows: { select: [[RAIN_ROW]] },
433
+ directory,
434
+ });
435
+ const result = await svc.sendRain({
436
+ amount: '10.00000000',
437
+ recipientCount: 1,
438
+ roomId: ROOM_ID,
439
+ idempotencyKey: IDEMPOTENCY_KEY,
440
+ onlineUserIds: [CLAIMER_ID],
441
+ }, ACTOR_ID);
442
+ expect(result).toEqual({ ok: false, reason: 'player_not_found', playerId: CLAIMER_ID });
443
+ });
444
+ it('returns { ok: false, reason: "no_online_users" } when onlineUserIds only contains the actor', async () => {
445
+ const svc = makeSvc({ drizzleRows: { select: [[RAIN_ROW]] } });
446
+ const result = await svc.sendRain({
447
+ amount: '10.00000000',
448
+ recipientCount: 5,
449
+ roomId: ROOM_ID,
450
+ idempotencyKey: IDEMPOTENCY_KEY,
451
+ onlineUserIds: [ACTOR_ID],
452
+ }, ACTOR_ID);
453
+ expect(result).toEqual({ ok: false, reason: 'no_online_users' });
454
+ });
455
+ it('replays the stored result without debiting the wallet again', async () => {
456
+ const wallet = makeWallet();
457
+ const svc = makeSvc({
458
+ drizzleRows: { select: [[RAIN_ROW]] },
459
+ cache: makeCache({
460
+ [RAIN_IDEMPOTENCY_CACHE_KEY]: {
461
+ fingerprint: fingerprintCommand({
462
+ type: 'rain',
463
+ amount: '10.99000000',
464
+ recipientCount: 10,
465
+ roomId: ROOM_ID,
466
+ idempotencyKey: IDEMPOTENCY_KEY,
467
+ }),
468
+ result: SYSTEM_MSG,
469
+ },
470
+ }),
471
+ wallet,
472
+ });
473
+ const result = await svc.sendRain({
474
+ amount: '10.99000000',
475
+ recipientCount: 10,
476
+ roomId: ROOM_ID,
477
+ idempotencyKey: IDEMPOTENCY_KEY,
478
+ onlineUserIds: [],
479
+ }, ACTOR_ID);
480
+ expect(result).toEqual({ ok: true, message: SYSTEM_MSG });
481
+ expect(wallet.debit).not.toHaveBeenCalled();
482
+ });
483
+ it('distributes to online recipients excluding the actor, persists a player_rain row + receivers, posts a system message with rain metadata inside the transaction, and publishes after commit', async () => {
484
+ const wallet = makeWallet();
485
+ const writer = makeWriter();
486
+ const transport = makeTransport();
487
+ const svc = makeSvc({
488
+ drizzleRows: {
489
+ select: [[RAIN_ROW]],
490
+ returning: [[{ id: '00000000-0000-0000-0000-0000000000cc' }]],
491
+ execute: [[{ per_recipient: '5.00000000', total_distributed: '10.00000000' }]],
492
+ },
493
+ wallet,
494
+ writer,
495
+ transport,
496
+ });
497
+ const result = await svc.sendRain({
498
+ amount: '10.00000000',
499
+ recipientCount: 2,
500
+ roomId: ROOM_ID,
501
+ idempotencyKey: IDEMPOTENCY_KEY,
502
+ onlineUserIds: [ACTOR_ID, CLAIMER_ID, RECIPIENT_2],
503
+ }, ACTOR_ID);
504
+ expect(result.ok).toBe(true);
505
+ expect(wallet.credit).toHaveBeenCalledTimes(2);
506
+ expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), {
507
+ userId: CLAIMER_ID,
508
+ amount: '5.00000000',
509
+ currency: 'USD',
510
+ type: 'rain',
511
+ });
512
+ expect(writer.postSystemMessage).toHaveBeenCalledOnce();
513
+ expect(writer.postSystemMessage).toHaveBeenCalledWith(expect.objectContaining({
514
+ roomId: ROOM_ID,
515
+ actorId: ACTOR_ID,
516
+ metadata: expect.objectContaining({
517
+ command: 'rain',
518
+ fromUserId: ACTOR_ID,
519
+ fromUsername: 'bob',
520
+ amount: '10.00000000',
521
+ currency: 'USD',
522
+ recipientCount: 2,
523
+ perRecipient: '5.00000000',
524
+ recipients: expect.arrayContaining([
525
+ { userId: CLAIMER_ID, username: 'alice' },
526
+ { userId: RECIPIENT_2, username: 'charlie' },
527
+ ]),
528
+ }),
529
+ }));
530
+ expect(transport.publish).toHaveBeenCalledWith(`chat:room:${ROOM_ID}`, SYSTEM_MSG);
531
+ });
532
+ it('debits only the amount actually distributed (floor(amount/n)*n), never the raw player-typed amount', async () => {
533
+ const wallet = makeWallet();
534
+ const svc = makeSvc({
535
+ drizzleRows: {
536
+ select: [[RAIN_ROW]],
537
+ returning: [[{ id: '00000000-0000-0000-0000-0000000000cc' }]],
538
+ execute: [[{ per_recipient: '1.00000000', total_distributed: '10.00000000' }]],
539
+ },
540
+ wallet,
541
+ directory: makeDirectory('bob', 'alice', Array.from({ length: 10 }, (_, i) => `00000000-0000-0000-0000-0000000000${10 + i}`)),
542
+ });
543
+ await svc.sendRain({
544
+ amount: '10.99000000',
545
+ recipientCount: 10,
546
+ roomId: ROOM_ID,
547
+ idempotencyKey: IDEMPOTENCY_KEY,
548
+ onlineUserIds: Array.from({ length: 10 }, (_, i) => `00000000-0000-0000-0000-0000000000${10 + i}`),
549
+ }, ACTOR_ID);
550
+ expect(wallet.debit).toHaveBeenCalledWith(expect.anything(), {
551
+ userId: ACTOR_ID,
552
+ amount: '10.00000000',
553
+ type: 'rain',
554
+ });
555
+ });
556
+ it('returns { ok: false, reason: "below_minimum" } when amount is below config minAmount', async () => {
557
+ const svc = makeSvc({
558
+ drizzleRows: { select: [[{ ...RAIN_ROW, config: { minAmount: '5.00000000' } }]] },
559
+ });
560
+ const result = await svc.sendRain({
561
+ amount: '1.00000000',
562
+ recipientCount: 1,
563
+ roomId: ROOM_ID,
564
+ idempotencyKey: IDEMPOTENCY_KEY,
565
+ onlineUserIds: [],
566
+ }, ACTOR_ID);
567
+ expect(result).toEqual({ ok: false, reason: 'below_minimum' });
568
+ });
569
+ it('returns { ok: false, reason: "exceeds_limit" } when amount is above config maxAmount', async () => {
570
+ const svc = makeSvc({
571
+ drizzleRows: { select: [[{ ...RAIN_ROW, config: { maxAmount: '10.00000000' } }]] },
572
+ });
573
+ const result = await svc.sendRain({
574
+ amount: '50.00000000',
575
+ recipientCount: 1,
576
+ roomId: ROOM_ID,
577
+ idempotencyKey: IDEMPOTENCY_KEY,
578
+ onlineUserIds: [],
579
+ }, ACTOR_ID);
580
+ expect(result).toEqual({ ok: false, reason: 'exceeds_limit' });
581
+ });
582
+ it('returns { ok: false, reason: "exceeds_limit" } when recipientCount exceeds config maxRecipients', async () => {
583
+ const svc = makeSvc({
584
+ drizzleRows: { select: [[{ ...RAIN_ROW, config: { maxRecipients: 10 } }]] },
585
+ });
586
+ const result = await svc.sendRain({
587
+ amount: '100.00000000',
588
+ recipientCount: 11,
589
+ roomId: ROOM_ID,
590
+ idempotencyKey: IDEMPOTENCY_KEY,
591
+ onlineUserIds: [],
592
+ }, ACTOR_ID);
593
+ expect(result).toEqual({ ok: false, reason: 'exceeds_limit' });
594
+ });
595
+ it('returns { ok: false, reason: "too_many_recipients" } when recipientCount exceeds the whole-dollar amount', async () => {
596
+ const svc = makeSvc({ drizzleRows: { select: [[RAIN_ROW]] } });
597
+ const result = await svc.sendRain({
598
+ amount: '3.00000000',
599
+ recipientCount: 4,
600
+ roomId: ROOM_ID,
601
+ idempotencyKey: IDEMPOTENCY_KEY,
602
+ onlineUserIds: [],
603
+ }, ACTOR_ID);
604
+ expect(result).toEqual({ ok: false, reason: 'too_many_recipients' });
605
+ });
606
+ });
607
+ const DONATE_ROW = {
608
+ key: 'donate',
609
+ enabled: true,
610
+ label: 'Donate',
611
+ description: 'Send a direct tip to a specific player',
612
+ config: null,
613
+ updatedAt: new Date(),
614
+ };
615
+ const DONATE_SYSTEM_MSG = {
616
+ id: MSG_ID,
617
+ roomId: ROOM_ID,
618
+ actorId: ACTOR_ID,
619
+ userId: ACTOR_ID,
620
+ username: 'system',
621
+ content: '',
622
+ type: 'system',
623
+ isDeleted: false,
624
+ metadata: {
625
+ command: 'donate',
626
+ senderId: ACTOR_ID,
627
+ senderUsername: 'bob',
628
+ recipientId: CLAIMER_ID,
629
+ recipientUsername: 'alice',
630
+ amount: '10.00000000',
631
+ currency: 'USD',
632
+ },
633
+ createdAt: new Date().toISOString(),
634
+ };
635
+ /**
636
+ * Directory mock where findPlayerIds resolves the RECIPIENT (CLAIMER_ID = 'alice').
637
+ * Used for donate tests that target 'alice' - the default makeDirectory() always
638
+ * returns ACTOR_ID which only has username 'bob', so alice is never found.
639
+ */
640
+ function makeRecipientDirectory() {
641
+ const all = [
642
+ {
643
+ userId: ACTOR_ID,
644
+ username: 'bob',
645
+ email: 'bob@example.com',
646
+ kycStatus: null,
647
+ language: null,
648
+ avatarUrl: null,
649
+ createdAt: DIRECTORY_CREATED_AT,
650
+ level: 3,
651
+ currency: 'USD',
652
+ },
653
+ {
654
+ userId: CLAIMER_ID,
655
+ username: 'alice',
656
+ email: 'alice@example.com',
657
+ kycStatus: null,
658
+ language: null,
659
+ avatarUrl: null,
660
+ createdAt: DIRECTORY_CREATED_AT,
661
+ level: 1,
662
+ currency: 'USD',
663
+ },
664
+ ];
665
+ return mock({
666
+ findPlayerIds: vi.fn().mockResolvedValue([CLAIMER_ID]),
667
+ lookupPlayers: vi.fn().mockImplementation((ids) => {
668
+ return Promise.resolve(all.filter((p) => ids.includes(p.userId)));
669
+ }),
670
+ getPlayerByUsername: vi.fn().mockImplementation((username) => {
671
+ return Promise.resolve(all.find((p) => p.username.toLowerCase() === username.toLowerCase()) ?? null);
672
+ }),
673
+ });
674
+ }
675
+ describe('SocialTransfersService.sendDonate', () => {
676
+ it('debits sender, credits recipient, and returns the system message on success', async () => {
677
+ const wallet = makeWallet();
678
+ const writer = mock({
679
+ postSystemMessage: vi.fn().mockResolvedValue(DONATE_SYSTEM_MSG),
680
+ });
681
+ const svc = makeSvc({
682
+ drizzleRows: {
683
+ select: [[DONATE_ROW]],
684
+ returning: [[{ id: '00000000-0000-0000-0000-0000000000dd' }]],
685
+ },
686
+ wallet,
687
+ writer,
688
+ directory: makeRecipientDirectory(),
689
+ });
690
+ const result = await svc.sendDonate({
691
+ targetUsername: 'alice',
692
+ amount: '10.00000000',
693
+ roomId: ROOM_ID,
694
+ idempotencyKey: IDEMPOTENCY_KEY,
695
+ }, ACTOR_ID);
696
+ expect(wallet.debit).toHaveBeenCalledWith(expect.anything(), {
697
+ userId: ACTOR_ID,
698
+ amount: '10.00000000',
699
+ type: 'tip',
700
+ });
701
+ expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), {
702
+ userId: CLAIMER_ID,
703
+ amount: '10.00000000',
704
+ currency: 'USD',
705
+ type: 'tip',
706
+ });
707
+ expect(result.id).toBe(MSG_ID);
708
+ });
709
+ it('throws DonateSelfError when the sender targets themselves', async () => {
710
+ const svc = makeSvc({ drizzleRows: { select: [[DONATE_ROW]] } });
711
+ await expect(svc.sendDonate({
712
+ targetUsername: 'bob',
713
+ amount: '10.00000000',
714
+ roomId: ROOM_ID,
715
+ idempotencyKey: IDEMPOTENCY_KEY,
716
+ }, ACTOR_ID)).rejects.toThrow(DonateSelfError);
717
+ });
718
+ it('throws ChatPlayerNotFoundError when the target username does not exist', async () => {
719
+ const directory = mock({
720
+ findPlayerIds: vi.fn().mockResolvedValue([]),
721
+ lookupPlayers: vi.fn().mockResolvedValue([]),
722
+ getPlayerByUsername: vi.fn().mockResolvedValue(null),
723
+ });
724
+ const svc = makeSvc({ drizzleRows: { select: [[DONATE_ROW]] }, directory });
725
+ await expect(svc.sendDonate({
726
+ targetUsername: 'ghost',
727
+ amount: '10.00000000',
728
+ roomId: ROOM_ID,
729
+ idempotencyKey: IDEMPOTENCY_KEY,
730
+ }, ACTOR_ID)).rejects.toThrow(ChatPlayerNotFoundError);
731
+ });
732
+ it('throws InsufficientBalanceError when the sender wallet debit fails', async () => {
733
+ const svc = makeSvc({
734
+ drizzleRows: { select: [[DONATE_ROW]], returning: [[{ id: IDEMPOTENCY_ROW_ID }]] },
735
+ wallet: makeWallet(false),
736
+ directory: makeRecipientDirectory(),
737
+ });
738
+ await expect(svc.sendDonate({
739
+ targetUsername: 'alice',
740
+ amount: '10.00000000',
741
+ roomId: ROOM_ID,
742
+ idempotencyKey: IDEMPOTENCY_KEY,
743
+ }, ACTOR_ID)).rejects.toThrow(InsufficientBalanceError);
744
+ });
745
+ it('throws BelowMinimumError when amount is below config minAmount', async () => {
746
+ const svc = makeSvc({
747
+ drizzleRows: { select: [[{ ...DONATE_ROW, config: { minAmount: '5.00000000' } }]] },
748
+ });
749
+ await expect(svc.sendDonate({
750
+ targetUsername: 'alice',
751
+ amount: '1.00000000',
752
+ roomId: ROOM_ID,
753
+ idempotencyKey: IDEMPOTENCY_KEY,
754
+ }, ACTOR_ID)).rejects.toThrow(BelowMinimumError);
755
+ });
756
+ });
757
+ //# sourceMappingURL=social-transfers.service.test.js.map