@nadohq/mobile-client 0.52.0 → 0.54.0

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 (147) hide show
  1. package/README.md +12 -4
  2. package/dist/{MobileClient-CEVEYnV1.cjs → MobileClient-BbTaqTzi.cjs} +141 -3
  3. package/dist/MobileClient-BbTaqTzi.cjs.map +1 -0
  4. package/dist/MobileClient.cjs +1 -1
  5. package/dist/MobileClient.d.cts +75 -8
  6. package/dist/MobileClient.d.cts.map +1 -1
  7. package/dist/MobileClient.d.ts +75 -8
  8. package/dist/MobileClient.d.ts.map +1 -1
  9. package/dist/MobileClient.js +141 -3
  10. package/dist/MobileClient.js.map +1 -1
  11. package/dist/{MobileServerFailureError-GsGrBgO0.d.ts → MobileServerFailureError-B2og8e5O.d.ts} +3 -3
  12. package/dist/{MobileServerFailureError-GsGrBgO0.d.ts.map → MobileServerFailureError-B2og8e5O.d.ts.map} +1 -1
  13. package/dist/{MobileServerFailureError-D4ina2b2.d.cts → MobileServerFailureError-BrLtaHnU.d.cts} +3 -3
  14. package/dist/{MobileServerFailureError-D4ina2b2.d.cts.map → MobileServerFailureError-BrLtaHnU.d.cts.map} +1 -1
  15. package/dist/{buildSignedMobileRequest-D9XzRTyq.d.cts → buildSignedMobileRequest-CZ-EdxYI.d.cts} +2 -2
  16. package/dist/{buildSignedMobileRequest-D9XzRTyq.d.cts.map → buildSignedMobileRequest-CZ-EdxYI.d.cts.map} +1 -1
  17. package/dist/{buildSignedMobileRequest-7Iwi5k6h.d.ts → buildSignedMobileRequest-CoAVSDPx.d.ts} +2 -2
  18. package/dist/{buildSignedMobileRequest-7Iwi5k6h.d.ts.map → buildSignedMobileRequest-CoAVSDPx.d.ts.map} +1 -1
  19. package/dist/{canonicalize-OYR35XMP.d.cts → canonicalize-0_B6UOxO.d.cts} +2 -2
  20. package/dist/canonicalize-0_B6UOxO.d.cts.map +1 -0
  21. package/dist/{canonicalize-T4ShepBu.d.ts → canonicalize-BC-JICjp.d.ts} +2 -2
  22. package/dist/canonicalize-BC-JICjp.d.ts.map +1 -0
  23. package/dist/clientTypes-P4LAjreY.d.ts +731 -0
  24. package/dist/clientTypes-P4LAjreY.d.ts.map +1 -0
  25. package/dist/clientTypes-YF5d8bvg.d.cts +731 -0
  26. package/dist/clientTypes-YF5d8bvg.d.cts.map +1 -0
  27. package/dist/dataMappers.cjs +358 -0
  28. package/dist/dataMappers.cjs.map +1 -1
  29. package/dist/dataMappers.d.cts +132 -6
  30. package/dist/dataMappers.d.cts.map +1 -1
  31. package/dist/dataMappers.d.ts +132 -6
  32. package/dist/dataMappers.d.ts.map +1 -1
  33. package/dist/dataMappers.js +339 -2
  34. package/dist/dataMappers.js.map +1 -1
  35. package/dist/{eip712MethodByType-D6edRSIs.d.ts → eip712MethodByType-DWtLyPY7.d.ts} +2 -2
  36. package/dist/eip712MethodByType-DWtLyPY7.d.ts.map +1 -0
  37. package/dist/{eip712MethodByType-BAqld4fP.d.cts → eip712MethodByType-sHQ-VTHd.d.cts} +2 -2
  38. package/dist/eip712MethodByType-sHQ-VTHd.d.cts.map +1 -0
  39. package/dist/index.cjs +1 -1
  40. package/dist/index.d.cts +15 -15
  41. package/dist/index.d.ts +15 -15
  42. package/dist/{mobileErrorCodes-CGYR3_Io.d.ts → mobileErrorCodes-DccrM4NI.d.cts} +8 -2
  43. package/dist/{mobileErrorCodes-CGYR3_Io.d.cts.map → mobileErrorCodes-DccrM4NI.d.cts.map} +1 -1
  44. package/dist/{mobileErrorCodes-CGYR3_Io.d.cts → mobileErrorCodes-DccrM4NI.d.ts} +8 -2
  45. package/dist/{mobileErrorCodes-CGYR3_Io.d.ts.map → mobileErrorCodes-DccrM4NI.d.ts.map} +1 -1
  46. package/dist/{payloadHash-B7Nmf64N.d.cts → payloadHash-CSgTglN4.d.cts} +2 -2
  47. package/dist/payloadHash-CSgTglN4.d.cts.map +1 -0
  48. package/dist/{payloadHash-RxkbwgTH.d.ts → payloadHash-qsU1DEF0.d.ts} +2 -2
  49. package/dist/payloadHash-qsU1DEF0.d.ts.map +1 -0
  50. package/dist/{serverBaseTypes-BOuAdmuQ.d.cts → serverBaseTypes-CuMsGXgc.d.cts} +2 -2
  51. package/dist/{serverBaseTypes-BOuAdmuQ.d.cts.map → serverBaseTypes-CuMsGXgc.d.cts.map} +1 -1
  52. package/dist/{serverBaseTypes-DDzfBA1a.d.ts → serverBaseTypes-DKCLDn3x.d.ts} +2 -2
  53. package/dist/{serverBaseTypes-DDzfBA1a.d.ts.map → serverBaseTypes-DKCLDn3x.d.ts.map} +1 -1
  54. package/dist/{serverExecuteTypes-DrxK2r7m.d.ts → serverExecuteTypes-DKG1mqy1.d.ts} +3 -3
  55. package/dist/{serverExecuteTypes-DrxK2r7m.d.ts.map → serverExecuteTypes-DKG1mqy1.d.ts.map} +1 -1
  56. package/dist/{serverExecuteTypes-DamHfu25.d.cts → serverExecuteTypes-TPX0GCgv.d.cts} +3 -3
  57. package/dist/{serverExecuteTypes-DamHfu25.d.cts.map → serverExecuteTypes-TPX0GCgv.d.cts.map} +1 -1
  58. package/dist/serverModelTypes-MlqdaMRd.d.cts +490 -0
  59. package/dist/serverModelTypes-MlqdaMRd.d.cts.map +1 -0
  60. package/dist/serverModelTypes-MlqdaMRd.d.ts +490 -0
  61. package/dist/serverModelTypes-MlqdaMRd.d.ts.map +1 -0
  62. package/dist/serverQueryTypes-BLUe10AI.d.ts +152 -0
  63. package/dist/serverQueryTypes-BLUe10AI.d.ts.map +1 -0
  64. package/dist/serverQueryTypes-BZOoJBpb.d.cts +152 -0
  65. package/dist/serverQueryTypes-BZOoJBpb.d.cts.map +1 -0
  66. package/dist/{serverRequestTypes-BylecTUn.d.cts → serverRequestTypes-BULjZnnz.d.ts} +69 -4
  67. package/dist/serverRequestTypes-BULjZnnz.d.ts.map +1 -0
  68. package/dist/{serverRequestTypes-DcdmGfTu.d.ts → serverRequestTypes-XbnLUsXT.d.cts} +69 -4
  69. package/dist/serverRequestTypes-XbnLUsXT.d.cts.map +1 -0
  70. package/dist/{serverResponseGuards-CjR1tv7G.d.cts → serverResponseGuards-DIj9MIrJ.d.cts} +2 -2
  71. package/dist/{serverResponseGuards-CjR1tv7G.d.cts.map → serverResponseGuards-DIj9MIrJ.d.cts.map} +1 -1
  72. package/dist/{serverResponseGuards-BspbRjOM.d.ts → serverResponseGuards-DY_8lAo7.d.ts} +2 -2
  73. package/dist/{serverResponseGuards-BspbRjOM.d.ts.map → serverResponseGuards-DY_8lAo7.d.ts.map} +1 -1
  74. package/dist/signing/buildSignedMobileRequest.d.cts +1 -1
  75. package/dist/signing/buildSignedMobileRequest.d.ts +1 -1
  76. package/dist/signing/canonicalize.d.cts +1 -1
  77. package/dist/signing/canonicalize.d.ts +1 -1
  78. package/dist/signing/eip712MethodByType.d.cts +1 -1
  79. package/dist/signing/eip712MethodByType.d.ts +1 -1
  80. package/dist/signing/index.d.cts +5 -5
  81. package/dist/signing/index.d.ts +5 -5
  82. package/dist/signing/payloadHash.d.cts +1 -1
  83. package/dist/signing/payloadHash.d.ts +1 -1
  84. package/dist/signing/types.d.cts +1 -1
  85. package/dist/signing/types.d.ts +1 -1
  86. package/dist/types/MobileServerFailureError.d.cts +1 -1
  87. package/dist/types/MobileServerFailureError.d.ts +1 -1
  88. package/dist/types/clientTypes.d.cts +2 -2
  89. package/dist/types/clientTypes.d.ts +2 -2
  90. package/dist/types/index.d.cts +9 -9
  91. package/dist/types/index.d.ts +9 -9
  92. package/dist/types/mobileErrorCodes.cjs +13 -1
  93. package/dist/types/mobileErrorCodes.cjs.map +1 -1
  94. package/dist/types/mobileErrorCodes.d.cts +1 -1
  95. package/dist/types/mobileErrorCodes.d.ts +1 -1
  96. package/dist/types/mobileErrorCodes.js +13 -1
  97. package/dist/types/mobileErrorCodes.js.map +1 -1
  98. package/dist/types/serverBaseTypes.d.cts +1 -1
  99. package/dist/types/serverBaseTypes.d.ts +1 -1
  100. package/dist/types/serverExecuteTypes.d.cts +1 -1
  101. package/dist/types/serverExecuteTypes.d.ts +1 -1
  102. package/dist/types/serverModelTypes.cjs.map +1 -1
  103. package/dist/types/serverModelTypes.d.cts +2 -2
  104. package/dist/types/serverModelTypes.d.ts +2 -2
  105. package/dist/types/serverModelTypes.js.map +1 -1
  106. package/dist/types/serverQueryTypes.d.cts +2 -2
  107. package/dist/types/serverQueryTypes.d.ts +2 -2
  108. package/dist/types/serverRequestTypes.d.cts +2 -2
  109. package/dist/types/serverRequestTypes.d.ts +2 -2
  110. package/dist/{types-BG321aOe.d.ts → types-B_4veFLc.d.ts} +3 -3
  111. package/dist/{types-BG321aOe.d.ts.map → types-B_4veFLc.d.ts.map} +1 -1
  112. package/dist/{types-D30luCKT.d.cts → types-CwpXP7xM.d.cts} +3 -3
  113. package/dist/{types-D30luCKT.d.cts.map → types-CwpXP7xM.d.cts.map} +1 -1
  114. package/dist/utils/index.d.cts +1 -1
  115. package/dist/utils/index.d.ts +1 -1
  116. package/dist/utils/serverResponseGuards.d.cts +1 -1
  117. package/dist/utils/serverResponseGuards.d.ts +1 -1
  118. package/package.json +3 -3
  119. package/src/MobileClient.ts +192 -2
  120. package/src/dataMappers.ts +503 -1
  121. package/src/socialFeed.test.ts +723 -0
  122. package/src/types/clientTypes.ts +467 -0
  123. package/src/types/mobileErrorCodes.ts +12 -0
  124. package/src/types/serverModelTypes.ts +388 -0
  125. package/src/types/serverQueryTypes.ts +74 -0
  126. package/src/types/serverRequestTypes.ts +94 -1
  127. package/dist/MobileClient-CEVEYnV1.cjs.map +0 -1
  128. package/dist/canonicalize-OYR35XMP.d.cts.map +0 -1
  129. package/dist/canonicalize-T4ShepBu.d.ts.map +0 -1
  130. package/dist/clientTypes-FQTkDfq3.d.cts +0 -349
  131. package/dist/clientTypes-FQTkDfq3.d.cts.map +0 -1
  132. package/dist/clientTypes-FaHdWkNu.d.ts +0 -349
  133. package/dist/clientTypes-FaHdWkNu.d.ts.map +0 -1
  134. package/dist/eip712MethodByType-BAqld4fP.d.cts.map +0 -1
  135. package/dist/eip712MethodByType-D6edRSIs.d.ts.map +0 -1
  136. package/dist/payloadHash-B7Nmf64N.d.cts.map +0 -1
  137. package/dist/payloadHash-RxkbwgTH.d.ts.map +0 -1
  138. package/dist/serverModelTypes-YmHqfxc8.d.cts +0 -173
  139. package/dist/serverModelTypes-YmHqfxc8.d.cts.map +0 -1
  140. package/dist/serverModelTypes-YmHqfxc8.d.ts +0 -173
  141. package/dist/serverModelTypes-YmHqfxc8.d.ts.map +0 -1
  142. package/dist/serverQueryTypes-C-_h9awN.d.ts +0 -89
  143. package/dist/serverQueryTypes-C-_h9awN.d.ts.map +0 -1
  144. package/dist/serverQueryTypes-CuVsrzGV.d.cts +0 -89
  145. package/dist/serverQueryTypes-CuVsrzGV.d.cts.map +0 -1
  146. package/dist/serverRequestTypes-BylecTUn.d.cts.map +0 -1
  147. package/dist/serverRequestTypes-DcdmGfTu.d.ts.map +0 -1
@@ -0,0 +1,723 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { Subaccount, subaccountToHex } from '@nadohq/shared';
3
+ import { createWalletClient, http } from 'viem';
4
+ import { privateKeyToAccount } from 'viem/accounts';
5
+ import { ink } from 'viem/chains';
6
+ import {
7
+ mapMobileSocialFeedAuthor,
8
+ mapMobileSocialFeedInteraction,
9
+ mapMobileSocialFeedItemPatch,
10
+ mapMobileSocialFeedTopTradersPage,
11
+ mapMobileSocialFeedTradeCard,
12
+ mapMobileSocialFeedTradeRow,
13
+ mapMobileSocialFeedUpdates,
14
+ } from './dataMappers';
15
+ import { MobileClient } from './MobileClient';
16
+ import {
17
+ MOBILE_ERROR_CODES,
18
+ MobileServerFailureError,
19
+ MobileServerSocialFeedItemPatch,
20
+ MobileServerSocialFeedReadSession,
21
+ MobileServerSocialFeedTopTradersResponse,
22
+ MobileServerSocialFeedTradeCard,
23
+ MobileServerSocialFeedTradeRow,
24
+ MobileServerSocialFeedUpdatesResponse,
25
+ } from './types';
26
+
27
+ const itemId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
28
+ const positionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
29
+ const owner = privateKeyToAccount(
30
+ '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b786907',
31
+ );
32
+ const linked = privateKeyToAccount(
33
+ '0x0123456789012345678901234567890123456789012345678901234567890123',
34
+ );
35
+ const walletClient = createWalletClient({
36
+ account: owner,
37
+ chain: ink,
38
+ transport: http(),
39
+ });
40
+ const subaccount: Subaccount = {
41
+ subaccountOwner: owner.address,
42
+ subaccountName: 'default',
43
+ };
44
+
45
+ const row: MobileServerSocialFeedTradeRow = {
46
+ id: itemId,
47
+ kind: 'trade_row',
48
+ action: 'increased',
49
+ has_analysis: false,
50
+ drawing: null,
51
+ author: {
52
+ subaccount: subaccountToHex(subaccount),
53
+ username: null,
54
+ display_name: null,
55
+ avatar_url: null,
56
+ is_following: null,
57
+ trader_badge: null,
58
+ trader_data_status: 'unavailable',
59
+ },
60
+ created_at_ms: 40,
61
+ display_at_ms: 42,
62
+ version: '9007199254740993',
63
+ market: {
64
+ product_id: 2,
65
+ symbol: 'BTC-PERP',
66
+ logo_url: null,
67
+ category: 'crypto',
68
+ source_categories: ['crypto'],
69
+ },
70
+ capabilities: { can_like: false, can_comment: false, can_record_view: false },
71
+ position_id: positionId,
72
+ direction: 'long',
73
+ margin_mode: 'cross',
74
+ leverage: { value: null, basis: 'not_applicable' },
75
+ leverage_at_ms: null,
76
+ trade_count: 1,
77
+ quantity_x18: '123456789012345678901234567890',
78
+ notional_x18: '123456789012345678901234567890000',
79
+ average_price_x18: '2500123456789012345678',
80
+ };
81
+ const readSession: MobileServerSocialFeedReadSession = {
82
+ session_id: 'OpaqueSession',
83
+ mode: 'feed',
84
+ expires_at_ms: 42,
85
+ view_tracking: { min_visible_ratio: 0.5, min_duration_ms: 1000 },
86
+ update_cursor: null,
87
+ viewer_token: 'OpaqueViewer',
88
+ };
89
+ const emptyPage: MobileServerSocialFeedTopTradersResponse = {
90
+ read_session: readSession,
91
+ items: [],
92
+ next_cursor: null,
93
+ watermark: 'Watermark',
94
+ ranking_version: 'chronological-v1',
95
+ cohort: {
96
+ snapshot_id: 'Cohort',
97
+ status: 'ready',
98
+ coverage: {
99
+ scope: 'eligible_content_candidates',
100
+ total_authors: 0,
101
+ queried_authors: 0,
102
+ complete: true,
103
+ },
104
+ },
105
+ };
106
+ const emptyUpdates: MobileServerSocialFeedUpdatesResponse = {
107
+ item_patches: [],
108
+ position_patches: [],
109
+ unavailable_targets: [],
110
+ new_items_count: 0,
111
+ update_cursor: 'Update',
112
+ reset: true,
113
+ viewport_revision: 0,
114
+ request_seq: 1,
115
+ };
116
+
117
+ function mockClient(data?: unknown, status = 200) {
118
+ const client = new MobileClient({
119
+ url: 'https://mobile.example/v1/mobile',
120
+ walletClient,
121
+ });
122
+ const requests: Array<{
123
+ url: string | undefined;
124
+ body: Record<string, unknown>;
125
+ }> = [];
126
+ client.axiosInstance.defaults.adapter = (config) => {
127
+ requests.push({
128
+ url: config.url,
129
+ body: JSON.parse(String(config.data)) as Record<string, unknown>,
130
+ });
131
+ return Promise.resolve({
132
+ data: data ?? {
133
+ status: 'success',
134
+ ...(requests[requests.length - 1].body.type === 'feed_updates'
135
+ ? emptyUpdates
136
+ : requests[requests.length - 1].body.type === 'feed_item'
137
+ ? { item: row, read_session: readSession }
138
+ : emptyPage),
139
+ },
140
+ status,
141
+ statusText: 'mock',
142
+ headers: {},
143
+ config,
144
+ });
145
+ };
146
+ return { client, requests };
147
+ }
148
+
149
+ describe('[mobile-client]: social feed transport', () => {
150
+ it.each(['none', 'owner', 'linked'] as const)(
151
+ 'routes all eight reads publicly with %s wallet configuration',
152
+ async (walletMode) => {
153
+ const { client, requests } = mockClient();
154
+ if (walletMode === 'none') client.opts.walletClient = undefined;
155
+ if (walletMode === 'linked') {
156
+ client.setLinkedSigner(
157
+ createWalletClient({
158
+ account: linked,
159
+ chain: ink,
160
+ transport: http(),
161
+ }),
162
+ );
163
+ }
164
+ const viewAs = {
165
+ subaccountOwner: linked.address,
166
+ subaccountName: 'default',
167
+ };
168
+ await client.getForYouFeed({
169
+ minimumNotional: 500,
170
+ viewAs,
171
+ viewerToken: 'OpaqueViewer',
172
+ });
173
+ await client.getTopTradersFeed({ viewAs });
174
+ await client.getFriendsFeed({ viewAs });
175
+ await client.getProfileFeed({ subaccount, viewAs });
176
+ await client.getMarketFeed({ productId: 2, viewAs });
177
+ await client.getPositionFeed({
178
+ positionId: positionId.toUpperCase(),
179
+ viewAs,
180
+ });
181
+ await client.getFeedItem({
182
+ itemId: itemId.toUpperCase(),
183
+ commentPreviewLimit: 0,
184
+ viewAs,
185
+ });
186
+ await client.getFeedUpdates({
187
+ sessionId: 'session',
188
+ visibleItemIds: [],
189
+ visiblePositionIds: [],
190
+ viewportRevision: 0,
191
+ requestSeq: 0,
192
+ viewerToken: 'OpaqueViewer',
193
+ });
194
+ expect(requests.map(({ body }) => body.type)).toEqual([
195
+ 'for_you_feed',
196
+ 'top_traders_feed',
197
+ 'friends_feed',
198
+ 'profile_feed',
199
+ 'market_feed',
200
+ 'position_feed',
201
+ 'feed_item',
202
+ 'feed_updates',
203
+ ]);
204
+ for (const { url, body } of requests) {
205
+ expect(url).toBe('https://mobile.example/v1/mobile/public_query');
206
+ expect(body).not.toHaveProperty('signature');
207
+ expect(body).not.toHaveProperty('sender');
208
+ expect(body).not.toHaveProperty('nonce');
209
+ if (body.type === 'feed_updates') {
210
+ expect(body).not.toHaveProperty('view_as');
211
+ } else {
212
+ expect(body.view_as).toBe(subaccountToHex(viewAs));
213
+ }
214
+ }
215
+ expect(requests[0].body).toEqual({
216
+ type: 'for_you_feed',
217
+ minimum_notional: 500,
218
+ view_as: subaccountToHex(viewAs),
219
+ viewer_token: 'OpaqueViewer',
220
+ });
221
+ expect(requests[3].body.subaccount).toBe(subaccountToHex(subaccount));
222
+ expect(requests[3].body.subaccount).not.toBe(requests[3].body.view_as);
223
+ expect(requests[5].body.position_id).toBe(positionId.toUpperCase());
224
+ expect(requests[6].body.item_id).toBe(itemId.toUpperCase());
225
+ expect(requests[6].body.comment_preview_limit).toBe(0);
226
+ expect(requests[7].body).toEqual({
227
+ type: 'feed_updates',
228
+ session_id: 'session',
229
+ visible_item_ids: [],
230
+ visible_position_ids: [],
231
+ viewport_revision: 0,
232
+ request_seq: 0,
233
+ viewer_token: 'OpaqueViewer',
234
+ });
235
+ },
236
+ );
237
+
238
+ it('preserves update IDs, session credentials, and cursors unchanged', async () => {
239
+ const { client, requests } = mockClient();
240
+ client.opts.walletClient = undefined;
241
+ await client.getFeedUpdates({
242
+ sessionId: 'OpaqueSession',
243
+ viewerToken: 'OpaqueViewer',
244
+ updateCursor: 'OpaqueCursor',
245
+ visibleItemIds: [itemId.toUpperCase()],
246
+ visiblePositionIds: [positionId.toUpperCase()],
247
+ viewportRevision: 0,
248
+ requestSeq: 0,
249
+ });
250
+ expect(requests[0].body).toEqual({
251
+ type: 'feed_updates',
252
+ session_id: 'OpaqueSession',
253
+ viewer_token: 'OpaqueViewer',
254
+ update_cursor: 'OpaqueCursor',
255
+ visible_item_ids: [itemId.toUpperCase()],
256
+ visible_position_ids: [positionId.toUpperCase()],
257
+ viewport_revision: 0,
258
+ request_seq: 0,
259
+ });
260
+ });
261
+
262
+ it('passes a nullable read-session baseline directly to updates', async () => {
263
+ const { client, requests } = mockClient();
264
+ const page = await client.getForYouFeed();
265
+ await client.getFeedUpdates({
266
+ sessionId: page.readSession.sessionId,
267
+ viewerToken: page.readSession.viewerToken,
268
+ updateCursor: page.readSession.updateCursor,
269
+ visibleItemIds: [],
270
+ visiblePositionIds: [],
271
+ viewportRevision: 0,
272
+ requestSeq: 1,
273
+ });
274
+ expect(requests[1].body.update_cursor).toBeNull();
275
+ expect(requests[1].body.viewer_token).toBe('OpaqueViewer');
276
+ });
277
+
278
+ it('browses without a wallet and never infers viewAs from the profile target', async () => {
279
+ const { client, requests } = mockClient({
280
+ status: 'success',
281
+ ...emptyPage,
282
+ });
283
+ client.opts.walletClient = undefined;
284
+ await client.getForYouFeed();
285
+ await client.getTopTradersFeed();
286
+ const friends = await client.getFriendsFeed({ viewAs: subaccount });
287
+ await client.getProfileFeed({ subaccount });
288
+ expect(friends.items).toEqual([]);
289
+ expect(requests[0].body).toEqual({ type: 'for_you_feed' });
290
+ expect(requests[1].body).toEqual({ type: 'top_traders_feed' });
291
+ expect(requests[2].body).toEqual({
292
+ type: 'friends_feed',
293
+ view_as: subaccountToHex(subaccount),
294
+ });
295
+ expect(requests[3].body).toEqual({
296
+ type: 'profile_feed',
297
+ subaccount: subaccountToHex(subaccount),
298
+ });
299
+ });
300
+
301
+ it('preserves fixed empty pages and never auto-refreshes or reorders a session', async () => {
302
+ const { client, requests } = mockClient({
303
+ status: 'success',
304
+ items: [],
305
+ next_cursor: 'NextWindow',
306
+ watermark: 'Watermark',
307
+ ranking_version: 'chronological-v1',
308
+ read_session: {
309
+ session_id: 'Session',
310
+ mode: 'feed',
311
+ expires_at_ms: 42,
312
+ view_tracking: { min_visible_ratio: 0.5, min_duration_ms: 1000 },
313
+ update_cursor: null,
314
+ viewer_token: 'Viewer',
315
+ },
316
+ });
317
+ const page = await client.getForYouFeed({
318
+ viewAs: subaccount,
319
+ sessionId: 'Session',
320
+ cursor: 'Window',
321
+ viewerToken: 'Viewer',
322
+ limit: 1,
323
+ });
324
+ expect(page.items).toEqual([]);
325
+ expect(page.nextCursor).toBe('NextWindow');
326
+ expect(page.readSession.viewerToken).toBe('Viewer');
327
+ expect(page).not.toHaveProperty('status');
328
+ expect(requests).toHaveLength(1);
329
+ expect(requests[0].body.view_as).toBe(subaccountToHex(subaccount));
330
+ });
331
+
332
+ it.each([
333
+ [
334
+ 400,
335
+ MOBILE_ERROR_CODES.INVALID_SOCIAL_FEED_PARAMS,
336
+ 'public_query_friends_feed',
337
+ ],
338
+ [
339
+ 400,
340
+ MOBILE_ERROR_CODES.SOCIAL_FEED_SESSION_MISMATCH,
341
+ 'public_query_feed_updates',
342
+ ],
343
+ [410, MOBILE_ERROR_CODES.SOCIAL_FEED_QUERY_REMOVED, 'query_for_you_feed'],
344
+ ])(
345
+ 'preserves HTTP %s and feed error %s without automatic retries',
346
+ async (httpStatus, errorCode, requestType) => {
347
+ const { client, requests } = mockClient(
348
+ {
349
+ status: 'failure',
350
+ error: 'Rejected feed request',
351
+ error_code: errorCode,
352
+ request_type: requestType,
353
+ },
354
+ httpStatus,
355
+ );
356
+ await expect(client.getForYouFeed()).rejects.toMatchObject({
357
+ name: 'MobileServerFailureError',
358
+ httpStatus,
359
+ errorCode,
360
+ requestType,
361
+ });
362
+ expect(requests).toHaveLength(1);
363
+ },
364
+ );
365
+
366
+ it('preserves typed HTTP failures and rejects non-envelope framework errors', async () => {
367
+ const { client } = mockClient(
368
+ {
369
+ status: 'failure',
370
+ error: 'expired',
371
+ error_code: MOBILE_ERROR_CODES.SOCIAL_FEED_SESSION_EXPIRED,
372
+ request_type: 'public_query_friends_feed',
373
+ },
374
+ 410,
375
+ );
376
+ await expect(
377
+ client.getFriendsFeed({ viewAs: subaccount }),
378
+ ).rejects.toMatchObject({
379
+ errorCode: 6312,
380
+ requestType: 'public_query_friends_feed',
381
+ httpStatus: 410,
382
+ });
383
+ await expect(
384
+ client.getFriendsFeed({ viewAs: subaccount }),
385
+ ).rejects.toBeInstanceOf(MobileServerFailureError);
386
+ await expect(
387
+ mockClient('invalid JSON', 422).client.getForYouFeed(),
388
+ ).rejects.toThrow('Unexpected response');
389
+ await expect(
390
+ mockClient({ items: [] }).client.getForYouFeed(),
391
+ ).rejects.toThrow('missing success envelope');
392
+ });
393
+ });
394
+
395
+ describe('[mobile-client]: social feed response mapping', () => {
396
+ it('preserves nullable Top Traders badges and maps available badges without losing page metadata', () => {
397
+ const server: MobileServerSocialFeedTopTradersResponse = {
398
+ ...emptyPage,
399
+ next_cursor: 'NextCursor',
400
+ items: [
401
+ row,
402
+ {
403
+ ...row,
404
+ id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
405
+ author: {
406
+ ...row.author,
407
+ trader_data_status: 'ready',
408
+ trader_badge: {
409
+ code: 'shark',
410
+ pnl_x18: '12345678901234567890123',
411
+ metric_key: 'account_pnl_30d_equity',
412
+ window: '30d',
413
+ source_as_of_ms: 40,
414
+ fetched_at_ms: 42,
415
+ snapshot_id: 'Cohort',
416
+ },
417
+ },
418
+ },
419
+ ],
420
+ };
421
+ const page = mapMobileSocialFeedTopTradersPage(server);
422
+
423
+ expect(page.items.map((item) => item.id)).toEqual(
424
+ server.items.map((item) => item.id),
425
+ );
426
+ expect(page.items[0].author.traderBadge).toBeNull();
427
+ expect(page.items[0].author.traderDataStatus).toBe('unavailable');
428
+ expect(page.items[1].author.traderDataStatus).toBe('ready');
429
+ expect(page.items[1].author.traderBadge?.pnl.toFixed()).toBe(
430
+ '12345678901234567890123',
431
+ );
432
+ expect(page.items[1].author.traderBadge?.snapshotId).toBe('Cohort');
433
+ expect(page.nextCursor).toBe('NextCursor');
434
+ expect(page.watermark).toBe('Watermark');
435
+ expect(page.rankingVersion).toBe('chronological-v1');
436
+ expect(page.readSession.sessionId).toBe(readSession.session_id);
437
+ expect(page.cohort).toEqual({
438
+ snapshotId: 'Cohort',
439
+ status: 'ready',
440
+ coverage: {
441
+ scope: 'eligible_content_candidates',
442
+ totalAuthors: 0,
443
+ queriedAuthors: 0,
444
+ complete: true,
445
+ },
446
+ });
447
+ });
448
+
449
+ it.each([true, false, null])(
450
+ 'preserves follow state %s and unavailable likes',
451
+ (isFollowing) => {
452
+ const author = mapMobileSocialFeedAuthor({
453
+ ...row.author,
454
+ is_following: isFollowing,
455
+ });
456
+ const interaction = mapMobileSocialFeedInteraction({
457
+ like_count: 0,
458
+ comment_count: 0,
459
+ view_count: 0,
460
+ liked_by_me: null,
461
+ });
462
+ expect(author.isFollowing).toBe(isFollowing);
463
+ expect(interaction.likedByMe).toBeNull();
464
+ },
465
+ );
466
+ it('preserves precision, signed zero, nullable liquidation facts, and omitted patch fields', () => {
467
+ const patch: MobileServerSocialFeedItemPatch = {
468
+ author: row.author,
469
+ display_at_ms: 42,
470
+ item_id: itemId,
471
+ version: '9007199254740993',
472
+ quantity_x18: '123456789012345678901234567890',
473
+ notional_x18: null,
474
+ average_price_x18: '0',
475
+ realized_pnl_delta_x18: '-1000000000000000001',
476
+ };
477
+ const mapped = mapMobileSocialFeedItemPatch(patch);
478
+ expect(mapped.quantity?.toFixed()).toBe(patch.quantity_x18);
479
+ expect(mapped.realizedPnlDelta?.toFixed()).toBe('-1000000000000000001');
480
+ expect(mapped.averagePrice?.isZero()).toBe(true);
481
+ expect(mapped.notional).toBeNull();
482
+ expect(mapped.version).toBe('9007199254740993');
483
+ expect(mapped).not.toHaveProperty('entryPrice');
484
+ expect(patch).toHaveProperty('quantity_x18');
485
+ });
486
+
487
+ it('converts every row price to decimal units while retaining x18 amounts', () => {
488
+ const mapped = mapMobileSocialFeedTradeRow(row);
489
+ expect(mapped.quantity.toFixed()).toBe(row.quantity_x18);
490
+ expect(mapped.notional?.toFixed()).toBe(row.notional_x18);
491
+ expect(mapped.averagePrice?.toFixed()).toBe('2500.123456789012345678');
492
+ expect(mapped).not.toHaveProperty('quantityX18');
493
+ const liquidated = mapMobileSocialFeedTradeRow({
494
+ ...row,
495
+ action: 'liquidated',
496
+ notional_x18: null,
497
+ average_price_x18: null,
498
+ entry_price_x18: '2600000000000000000000',
499
+ liquidation_oracle_price_x18: '2500000000000000000000',
500
+ liquidation_execution_price_x18: null,
501
+ realized_pnl_delta_x18: '-1000000000000000001',
502
+ });
503
+ expect(liquidated.action).toBe('liquidated');
504
+ if (liquidated.action !== 'liquidated')
505
+ throw new Error('Expected liquidation');
506
+ expect(liquidated.entryPrice?.toFixed()).toBe('2600');
507
+ expect(liquidated.liquidationOraclePrice?.toFixed()).toBe('2500');
508
+ expect(liquidated.liquidationExecutionPrice).toBeNull();
509
+ expect(liquidated.averagePrice).toBeNull();
510
+ expect(liquidated.notional).toBeNull();
511
+ expect(liquidated.realizedPnlDelta?.toFixed()).toBe('-1000000000000000001');
512
+ const execution = mapMobileSocialFeedTradeRow({
513
+ ...row,
514
+ action: 'liquidated',
515
+ notional_x18: row.notional_x18,
516
+ entry_price_x18: null,
517
+ liquidation_oracle_price_x18: null,
518
+ liquidation_execution_price_x18: '2499123456789012345678',
519
+ realized_pnl_delta_x18: null,
520
+ });
521
+ if (execution.action !== 'liquidated')
522
+ throw new Error('Expected liquidation');
523
+ expect(execution.liquidationExecutionPrice?.toFixed()).toBe(
524
+ '2499.123456789012345678',
525
+ );
526
+ expect(execution.liquidationOraclePrice).toBeNull();
527
+ expect(execution.entryPrice).toBeNull();
528
+ expect(execution.realizedPnlDelta).toBeNull();
529
+ });
530
+
531
+ it('maps price patches independently and distinguishes omission from explicit null', () => {
532
+ const patch = mapMobileSocialFeedItemPatch({
533
+ author: row.author,
534
+ display_at_ms: 42,
535
+ item_id: itemId,
536
+ version: '2',
537
+ entry_price_x18: '2600000000000000000000',
538
+ average_price_x18: '2500123456789012345678',
539
+ liquidation_oracle_price_x18: null,
540
+ liquidation_execution_price_x18: '2499000000000000000000',
541
+ leverage_at_ms: null,
542
+ });
543
+ expect(patch.entryPrice?.toFixed()).toBe('2600');
544
+ expect(patch.averagePrice?.toFixed()).toBe('2500.123456789012345678');
545
+ expect(patch.liquidationOraclePrice).toBeNull();
546
+ expect(patch.liquidationExecutionPrice?.toFixed()).toBe('2499');
547
+ expect(patch.leverageAtMs).toBeNull();
548
+ expect(patch).not.toHaveProperty('notional');
549
+ expect(patch).not.toHaveProperty('realizedPnlDelta');
550
+ expect(patch).not.toHaveProperty('interaction');
551
+ });
552
+
553
+ it('delivers reset author updates even when the item version is unchanged', async () => {
554
+ const previous = mapMobileSocialFeedTradeRow(row);
555
+ const patch: MobileServerSocialFeedItemPatch = {
556
+ item_id: itemId,
557
+ version: row.version,
558
+ display_at_ms: 100,
559
+ author: {
560
+ ...row.author,
561
+ username: 'renamed',
562
+ display_name: 'Renamed Trader',
563
+ avatar_url: 'https://example.com/avatar.png',
564
+ is_following: true,
565
+ trader_data_status: 'ready',
566
+ trader_badge: {
567
+ code: 'whale',
568
+ pnl_x18: '123456789012345678901234',
569
+ metric_key: 'account_pnl_30d_equity',
570
+ window: '30d',
571
+ source_as_of_ms: 90,
572
+ fetched_at_ms: 95,
573
+ snapshot_id: 'NewCohort',
574
+ },
575
+ },
576
+ };
577
+ const { client } = mockClient({
578
+ status: 'success',
579
+ ...emptyUpdates,
580
+ item_patches: [patch],
581
+ });
582
+ const updates = await client.getFeedUpdates({
583
+ sessionId: readSession.session_id,
584
+ viewerToken: readSession.viewer_token,
585
+ visibleItemIds: [previous.id],
586
+ visiblePositionIds: [],
587
+ viewportRevision: 0,
588
+ requestSeq: 1,
589
+ });
590
+ const mapped = updates.itemPatches[0];
591
+ expect(updates.reset).toBe(true);
592
+ expect(mapped.version).toBe(previous.version);
593
+ expect(previous.author.username).toBeNull();
594
+ expect(mapped.displayAtMs).toBe(100);
595
+ expect(mapped.author).toMatchObject({
596
+ subaccount: row.author.subaccount,
597
+ username: 'renamed',
598
+ displayName: 'Renamed Trader',
599
+ avatarUrl: 'https://example.com/avatar.png',
600
+ isFollowing: true,
601
+ traderDataStatus: 'ready',
602
+ traderBadge: { code: 'whale', snapshotId: 'NewCohort' },
603
+ });
604
+ expect(mapped.author.traderBadge?.pnl.toFixed()).toBe(
605
+ '123456789012345678901234',
606
+ );
607
+ expect(mapped).not.toHaveProperty('quantity');
608
+
609
+ const cleared = mapMobileSocialFeedItemPatch({
610
+ ...patch,
611
+ author: row.author,
612
+ });
613
+ expect(cleared.author).toMatchObject({
614
+ username: null,
615
+ displayName: null,
616
+ avatarUrl: null,
617
+ isFollowing: null,
618
+ traderBadge: null,
619
+ traderDataStatus: 'unavailable',
620
+ });
621
+ });
622
+
623
+ it('maps nested position PnL and revocations without losing sequence or baseline', () => {
624
+ const response: MobileServerSocialFeedUpdatesResponse = {
625
+ item_patches: [],
626
+ position_patches: [
627
+ {
628
+ position_id: positionId,
629
+ position_version: '3',
630
+ state_as_of_ms: 42,
631
+ entry_price_x18: null,
632
+ display_basis: 'closing_snapshot',
633
+ snapshot_at_ms: 40,
634
+ quantity_x18: '1000000000000000000',
635
+ notional_x18: '2000000000000000000',
636
+ state: 'closed',
637
+ history_quality: 'partial',
638
+ margin_mode: 'cross',
639
+ leverage: { value: null, basis: 'not_applicable' },
640
+ leverage_at_ms: null,
641
+ pnl: {
642
+ amount_x18: null,
643
+ basis: 'realized_incl_fees_funding',
644
+ mode: 'frozen',
645
+ as_of_ms: 42,
646
+ quality: 'pending',
647
+ },
648
+ },
649
+ ],
650
+ unavailable_targets: [
651
+ { type: 'item', id: itemId, reason: 'unavailable' },
652
+ ],
653
+ new_items_count: 0,
654
+ update_cursor: 'OpaqueUpdate',
655
+ reset: true,
656
+ viewport_revision: 2,
657
+ request_seq: 3,
658
+ };
659
+ const card: MobileServerSocialFeedTradeCard = {
660
+ ...row,
661
+ kind: 'trade_card',
662
+ action: 'opened',
663
+ text: '',
664
+ mentions: [],
665
+ author: {
666
+ ...row.author,
667
+ trader_badge: {
668
+ code: 'shark',
669
+ pnl_x18: '12345678901234567890123',
670
+ metric_key: 'account_pnl_30d_equity',
671
+ window: '30d',
672
+ source_as_of_ms: 40,
673
+ fetched_at_ms: 42,
674
+ snapshot_id: 'Cohort',
675
+ },
676
+ trader_data_status: 'ready',
677
+ },
678
+ position: {
679
+ ...response.position_patches[0],
680
+ product_id: 2,
681
+ market: row.market,
682
+ direction: 'long',
683
+ entry_price_x18: '2500123456789012345678',
684
+ pnl: {
685
+ ...response.position_patches[0].pnl,
686
+ amount_x18: '-1000000000000000001',
687
+ quality: 'ready',
688
+ },
689
+ },
690
+ interaction: {
691
+ like_count: 0,
692
+ comment_count: 0,
693
+ view_count: 0,
694
+ liked_by_me: null,
695
+ },
696
+ comment_preview: { items: [], total_count: 0, next_cursor: null },
697
+ };
698
+ const mappedCard = mapMobileSocialFeedTradeCard(card);
699
+ expect(mappedCard.position.entryPrice?.toFixed()).toBe(
700
+ '2500.123456789012345678',
701
+ );
702
+ expect(mappedCard.position.notional.toFixed()).toBe('2000000000000000000');
703
+ expect(mappedCard.position.pnl.amount?.toFixed()).toBe(
704
+ '-1000000000000000001',
705
+ );
706
+ expect(mappedCard.author.traderBadge?.pnl.toFixed()).toBe(
707
+ '12345678901234567890123',
708
+ );
709
+ const mapped = mapMobileSocialFeedUpdates(response);
710
+ expect(mapped.positionPatches[0].quantity.toFixed()).toBe(
711
+ '1000000000000000000',
712
+ );
713
+ expect(mapped.positionPatches[0].pnl.amount).toBeNull();
714
+ expect(mapped.positionPatches[0].pnl.quality).toBe('pending');
715
+ expect(mapped.unavailableTargets).toEqual(response.unavailable_targets);
716
+ expect(mapped).toMatchObject({
717
+ updateCursor: 'OpaqueUpdate',
718
+ reset: true,
719
+ viewportRevision: 2,
720
+ requestSeq: 3,
721
+ });
722
+ });
723
+ });