Skip to content

Commit bfab8f0

Browse files
authored
[C-5769] Use batch fetching to normalize core entity hooks (#11355)
1 parent 3932be3 commit bfab8f0

27 files changed

+1367
-307
lines changed

package-lock.json

+20-10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/common/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@metaplex-foundation/mpl-token-metadata": "2.5.2",
4545
"@optimizely/optimizely-sdk": "4.0.0",
4646
"@tanstack/react-query": "5.62.7",
47+
"@yornaath/batshit": "0.10.1",
4748
"async-retry": "1.3.3",
4849
"bn.js": "5.1.0",
4950
"dayjs": "1.10.7",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import {
2+
full,
3+
GetBulkPlaylistsRequest,
4+
HashId,
5+
Id,
6+
OptionalId
7+
} from '@audius/sdk'
8+
import { QueryClient } from '@tanstack/react-query'
9+
import { describe, it, expect, beforeEach, vi, MockInstance } from 'vitest'
10+
11+
import { userCollectionMetadataFromSDK } from '~/adapters/collection'
12+
13+
import { getCollectionsBatcher } from '../getCollectionsBatcher'
14+
import type { BatchContext } from '../types'
15+
16+
describe('getCollectionsBatcher', () => {
17+
const createMockSdkCollection = (id: number): full.PlaylistFull => ({
18+
id: Id.parse(id),
19+
userId: Id.parse(1),
20+
playlistName: `Test Collection ${id}`,
21+
description: '',
22+
artwork: {},
23+
isAlbum: false,
24+
isPrivate: false,
25+
isDelete: false,
26+
isStreamGated: false,
27+
isScheduledRelease: false,
28+
isImageAutogenerated: false,
29+
permalink: '',
30+
playlistContents: [],
31+
repostCount: 0,
32+
favoriteCount: 0,
33+
totalPlayCount: 0,
34+
trackCount: 0,
35+
blocknumber: 0,
36+
createdAt: '',
37+
updatedAt: '',
38+
followeeReposts: [],
39+
followeeFavorites: [],
40+
hasCurrentUserReposted: false,
41+
hasCurrentUserSaved: false,
42+
addedTimestamps: [],
43+
access: {
44+
stream: true,
45+
download: true
46+
},
47+
user: {
48+
albumCount: 0,
49+
artistPickTrackId: undefined,
50+
bio: '',
51+
coverPhoto: {
52+
_640x: '',
53+
_2000x: '',
54+
mirrors: []
55+
},
56+
followeeCount: 0,
57+
followerCount: 0,
58+
handle: 'test',
59+
id: Id.parse(1),
60+
isVerified: false,
61+
twitterHandle: '',
62+
instagramHandle: '',
63+
tiktokHandle: '',
64+
verifiedWithTwitter: false,
65+
verifiedWithInstagram: false,
66+
verifiedWithTiktok: false,
67+
website: '',
68+
donation: '',
69+
location: '',
70+
name: 'Test User',
71+
playlistCount: 0,
72+
profilePicture: {
73+
_150x150: '',
74+
_480x480: '',
75+
_1000x1000: '',
76+
mirrors: []
77+
},
78+
repostCount: 0,
79+
trackCount: 0,
80+
isDeactivated: false,
81+
isAvailable: true,
82+
ercWallet: '',
83+
splWallet: '',
84+
supporterCount: 0,
85+
supportingCount: 0,
86+
totalAudioBalance: 0,
87+
wallet: '',
88+
balance: '0',
89+
associatedWalletsBalance: '0',
90+
totalBalance: '0',
91+
waudioBalance: '0',
92+
associatedSolWalletsBalance: '0',
93+
blocknumber: 0,
94+
createdAt: '',
95+
isStorageV2: false,
96+
currentUserFolloweeFollowCount: 0,
97+
doesCurrentUserFollow: false,
98+
doesCurrentUserSubscribe: false,
99+
doesFollowCurrentUser: false,
100+
handleLc: 'test',
101+
updatedAt: '',
102+
coverPhotoSizes: '',
103+
coverPhotoCids: undefined,
104+
coverPhotoLegacy: undefined,
105+
profilePictureSizes: '',
106+
profilePictureCids: undefined,
107+
profilePictureLegacy: undefined,
108+
metadataMultihash: undefined,
109+
hasCollectibles: false,
110+
playlistLibrary: undefined,
111+
allowAiAttribution: false
112+
}
113+
})
114+
115+
const mockSdk = {
116+
full: {
117+
playlists: {
118+
getBulkPlaylists: vi
119+
.fn()
120+
.mockImplementation((params: GetBulkPlaylistsRequest) => {
121+
const collections = params.id?.map((collectionId) =>
122+
createMockSdkCollection(HashId.parse(collectionId))
123+
)
124+
return Promise.resolve({ data: collections })
125+
})
126+
}
127+
}
128+
} as unknown as BatchContext['sdk']
129+
130+
const mockContext: BatchContext = {
131+
sdk: mockSdk,
132+
currentUserId: null,
133+
queryClient: new QueryClient(),
134+
dispatch: vi.fn()
135+
}
136+
137+
beforeEach(() => {
138+
vi.clearAllMocks()
139+
})
140+
141+
it('fetches a single collection correctly', async () => {
142+
const batcher = getCollectionsBatcher(mockContext)
143+
const id = 1
144+
const result = await batcher.fetch(id)
145+
146+
expect(mockSdk.full.playlists.getBulkPlaylists).toHaveBeenCalledWith({
147+
id: [Id.parse(id)],
148+
userId: OptionalId.parse(null)
149+
})
150+
expect(result).toMatchObject(
151+
userCollectionMetadataFromSDK(createMockSdkCollection(id)) ?? {}
152+
)
153+
})
154+
155+
it('batches multiple collection requests and returns correct results to each caller', async () => {
156+
const batcher = getCollectionsBatcher(mockContext)
157+
const ids = [1, 2, 3]
158+
159+
// Make concurrent requests
160+
const results = await Promise.all(ids.map((id) => batcher.fetch(id)))
161+
162+
// Verify single bulk request was made
163+
expect(mockSdk.full.playlists.getBulkPlaylists).toHaveBeenCalledTimes(1)
164+
expect(mockSdk.full.playlists.getBulkPlaylists).toHaveBeenCalledWith({
165+
id: ids.map((id) => Id.parse(id)),
166+
userId: OptionalId.parse(null)
167+
})
168+
169+
// Verify each caller got their correct collection data
170+
results.forEach((result, index) => {
171+
expect(result).toMatchObject(
172+
userCollectionMetadataFromSDK(createMockSdkCollection(ids[index])) ?? {}
173+
)
174+
})
175+
})
176+
177+
it('creates separate batches when requests are not concurrent', async () => {
178+
const batcher = getCollectionsBatcher(mockContext)
179+
180+
// First batch of requests
181+
const firstBatchIds = [1, 2]
182+
const firstBatchResults = await Promise.all(
183+
firstBatchIds.map((id) => batcher.fetch(id))
184+
)
185+
186+
// Wait longer than the batch window
187+
await new Promise((resolve) => setTimeout(resolve, 20))
188+
189+
// Second batch of requests
190+
const secondBatchIds = [3, 4]
191+
const secondBatchResults = await Promise.all(
192+
secondBatchIds.map((id) => batcher.fetch(id))
193+
)
194+
195+
// Verify two separate bulk requests were made
196+
expect(mockSdk.full.playlists.getBulkPlaylists).toHaveBeenCalledTimes(2)
197+
expect(mockSdk.full.playlists.getBulkPlaylists).toHaveBeenNthCalledWith(1, {
198+
id: firstBatchIds.map((id) => Id.parse(id)),
199+
userId: OptionalId.parse(null)
200+
})
201+
expect(mockSdk.full.playlists.getBulkPlaylists).toHaveBeenNthCalledWith(2, {
202+
id: secondBatchIds.map((id) => Id.parse(id)),
203+
userId: OptionalId.parse(null)
204+
})
205+
206+
// Verify results for first batch
207+
firstBatchResults.forEach((result, index) => {
208+
expect(result).toMatchObject(
209+
userCollectionMetadataFromSDK(
210+
createMockSdkCollection(firstBatchIds[index])
211+
) ?? {}
212+
)
213+
})
214+
215+
// Verify results for second batch
216+
secondBatchResults.forEach((result, index) => {
217+
expect(result).toMatchObject(
218+
userCollectionMetadataFromSDK(
219+
createMockSdkCollection(secondBatchIds[index])
220+
) ?? {}
221+
)
222+
})
223+
})
224+
225+
it('handles missing collections in batch response', async () => {
226+
const existingId = 1
227+
const missingId = 999
228+
229+
// Mock API to only return data for existingId
230+
const mockBulkPlaylists = mockSdk.full.playlists
231+
.getBulkPlaylists as unknown as MockInstance<
232+
[GetBulkPlaylistsRequest],
233+
Promise<{ data: full.PlaylistFull[] }>
234+
>
235+
mockBulkPlaylists.mockImplementationOnce(
236+
(params: GetBulkPlaylistsRequest) => {
237+
const collections =
238+
params.id
239+
?.filter((id) => HashId.parse(id) === existingId)
240+
.map((id) => createMockSdkCollection(HashId.parse(id))) ?? []
241+
return Promise.resolve({ data: collections })
242+
}
243+
)
244+
245+
const batcher = getCollectionsBatcher(mockContext)
246+
const [missingResult, existingResult] = await Promise.all([
247+
batcher.fetch(missingId),
248+
batcher.fetch(existingId)
249+
])
250+
251+
// Verify existing collection is returned correctly
252+
expect(existingResult).toMatchObject(
253+
userCollectionMetadataFromSDK(createMockSdkCollection(existingId)) ?? {}
254+
)
255+
256+
// Verify missing collection returns null
257+
expect(missingResult).toBeNull()
258+
259+
// Verify single batch request was made with both IDs
260+
expect(mockSdk.full.playlists.getBulkPlaylists).toHaveBeenCalledTimes(1)
261+
expect(mockSdk.full.playlists.getBulkPlaylists).toHaveBeenCalledWith({
262+
id: [missingId, existingId].map((id) => Id.parse(id)),
263+
userId: OptionalId.parse(null)
264+
})
265+
})
266+
})

0 commit comments

Comments
 (0)