diff --git a/packages/common/src/api/tan-query/collection/__tests__/useCollectionByPermalink.test.ts b/packages/common/src/api/tan-query/collection/__tests__/useCollectionByPermalink.test.ts new file mode 100644 index 00000000000..f6ebe139d9b --- /dev/null +++ b/packages/common/src/api/tan-query/collection/__tests__/useCollectionByPermalink.test.ts @@ -0,0 +1,106 @@ +import { Id, OptionalId } from '@audius/sdk' +import { QueryClient } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { userCollectionMetadataFromSDK } from '~/adapters/collection' + +import { getCollectionByPermalinkQueryFn } from '../useCollectionByPermalink' + +vi.mock('~/adapters/collection', () => ({ + userCollectionMetadataFromSDK: vi.fn((c) => c) +})) + +const makeCollection = (playlist_id: number, permalink: string) => + ({ + playlist_id, + permalink, + playlist_contents: { track_ids: [] } + }) as any + +describe('getCollectionByPermalinkQueryFn', () => { + let queryClient: QueryClient + let sdk: { playlists: { getBulkPlaylists: ReturnType } } + + beforeEach(() => { + queryClient = new QueryClient() + sdk = { playlists: { getBulkPlaylists: vi.fn() } } + vi.mocked(userCollectionMetadataFromSDK).mockImplementation((c) => c as any) + }) + + it('returns the playlist id when the permalink lookup succeeds', async () => { + const permalink = '/dj/playlist/summer-mix-100' + sdk.playlists.getBulkPlaylists.mockResolvedValueOnce({ + data: [makeCollection(100, permalink)] + }) + + const result = await getCollectionByPermalinkQueryFn( + permalink, + null, + queryClient, + sdk + ) + + expect(result).toBe(100) + expect(sdk.playlists.getBulkPlaylists).toHaveBeenCalledTimes(1) + expect(sdk.playlists.getBulkPlaylists).toHaveBeenCalledWith({ + permalink: [permalink], + userId: OptionalId.parse(null) + }) + }) + + it('falls back to id lookup when permalink lookup is empty (hidden playlist, logged out)', async () => { + const permalink = '/dj/playlist/hidden-mix-200' + sdk.playlists.getBulkPlaylists + .mockResolvedValueOnce({ data: [] }) + .mockResolvedValueOnce({ data: [makeCollection(200, permalink)] }) + + const result = await getCollectionByPermalinkQueryFn( + permalink, + null, + queryClient, + sdk + ) + + expect(result).toBe(200) + expect(sdk.playlists.getBulkPlaylists).toHaveBeenCalledTimes(2) + expect(sdk.playlists.getBulkPlaylists).toHaveBeenNthCalledWith(2, { + id: [Id.parse(200)], + userId: OptionalId.parse(null) + }) + }) + + it('rejects an id-fallback result whose permalink does not match (collision guard)', async () => { + const requested = '/dj/playlist/looks-like-300' + const collidingPermalink = '/other/playlist/different-300' + sdk.playlists.getBulkPlaylists + .mockResolvedValueOnce({ data: [] }) + .mockResolvedValueOnce({ + data: [makeCollection(300, collidingPermalink)] + }) + + const result = await getCollectionByPermalinkQueryFn( + requested, + null, + queryClient, + sdk + ) + + expect(result).toBeUndefined() + expect(sdk.playlists.getBulkPlaylists).toHaveBeenCalledTimes(2) + }) + + it('returns undefined without a second call when the slug has no parseable id', async () => { + const permalink = '/dj/playlist/no-trailing-id' + sdk.playlists.getBulkPlaylists.mockResolvedValueOnce({ data: [] }) + + const result = await getCollectionByPermalinkQueryFn( + permalink, + null, + queryClient, + sdk + ) + + expect(result).toBeUndefined() + expect(sdk.playlists.getBulkPlaylists).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/common/src/api/tan-query/collection/useCollectionByPermalink.ts b/packages/common/src/api/tan-query/collection/useCollectionByPermalink.ts index ca7b331710f..fe50357fe6b 100644 --- a/packages/common/src/api/tan-query/collection/useCollectionByPermalink.ts +++ b/packages/common/src/api/tan-query/collection/useCollectionByPermalink.ts @@ -1,10 +1,11 @@ -import { OptionalId } from '@audius/sdk' +import { Id, OptionalId } from '@audius/sdk' import { useQuery, useQueryClient, QueryClient } from '@tanstack/react-query' import { pick } from 'lodash' import { userCollectionMetadataFromSDK } from '~/adapters/collection' import { useQueryContext } from '~/api/tan-query/utils' import { ID } from '~/models/Identifiers' +import { parsePlaylistIdFromPermalink } from '~/utils' import { TQCollection } from '../models' import { QUERY_KEYS } from '../queryKeys' @@ -30,22 +31,41 @@ export const getCollectionByPermalinkQueryFn = async ( queryClient: QueryClient, sdk: any ) => { + const userId = OptionalId.parse(currentUserId) + const { data = [] } = await sdk.playlists.getBulkPlaylists({ permalink: [permalink], - userId: OptionalId.parse(currentUserId) + userId }) const collection = userCollectionMetadataFromSDK(data[0]) if (collection) { - // Prime related entities - primeCollectionData({ - collections: [collection], - queryClient - }) + primeCollectionData({ collections: [collection], queryClient }) + return collection.playlist_id + } + + // Hidden (is_private) playlists are filtered out of permalink lookups + // for logged-out users, but ID-based lookups honor direct-link access. + // Retry with the id encoded in the permalink slug so anyone with the + // link can view the playlist. + const idFromSlug = parsePlaylistIdFromPermalink(permalink) + if (Number.isNaN(idFromSlug)) return undefined + + const { data: byId = [] } = await sdk.playlists.getBulkPlaylists({ + id: [Id.parse(idFromSlug)], + userId + }) + + const byIdCollection = userCollectionMetadataFromSDK(byId[0]) + // Guard against id-in-slug collisions: only accept the result if it + // truly maps back to the requested permalink. + if (!byIdCollection || byIdCollection.permalink !== permalink) { + return undefined } - return collection?.playlist_id + primeCollectionData({ collections: [byIdCollection], queryClient }) + return byIdCollection.playlist_id } export const useCollectionByPermalink = (