From 5db02db172da38698491486a8dc156d0a00673eb Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 20 Aug 2026 13:35:18 -0400 Subject: [PATCH] [discovery][lists] semi working lists and discovery --- apps/discovery/src/discovery.app.ts | 70 ++++++- apps/discovery/src/openapi.ts | 32 +++- apps/discovery/src/page-sources.ts | 35 ++++ .../src/test/integration/api.test.ts | 110 ++++++++++- apps/discovery/static/bulk.json | 114 ----------- apps/discovery/static/sections.json | 114 +++++++++++ apps/lists/src/curated-lists.ts | 144 ++++++++++++++ apps/lists/src/lists.app.ts | 120 ++++-------- apps/lists/src/test/integration/api.test.ts | 177 ++++++++++-------- apps/lists/static/curated-lists.json | 58 ++++++ 10 files changed, 685 insertions(+), 289 deletions(-) delete mode 100644 apps/discovery/static/bulk.json create mode 100644 apps/discovery/static/sections.json create mode 100644 apps/lists/src/curated-lists.ts create mode 100644 apps/lists/static/curated-lists.json diff --git a/apps/discovery/src/discovery.app.ts b/apps/discovery/src/discovery.app.ts index b6c5ee1..9511fe6 100644 --- a/apps/discovery/src/discovery.app.ts +++ b/apps/discovery/src/discovery.app.ts @@ -4,17 +4,23 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' -import { DiscoverySections, json, PAGE_SOURCE_PARAM, ServiceStatus } from './openapi' -import { fetchPageSource } from './page-sources' +import { + DiscoverySections, + json, + PAGE_SOURCE_PARAM, + SECTION_IDS_PARAM, + ServiceStatus, +} from './openapi' +import { fetchPageSource, readSections, SECTIONS_CATALOGUE } from './page-sources' import type { App } from './context' /** * Discovery Worker. Serves the layout of the client's discovery pages — which carousels a * page shows and in what order — out of `static/`, one file per page source, through the - * ASSETS binding (see `page-sources.ts`). It does not serve the carousels' CONTENTS: each - * section names a client-side feed the client resolves against the `rooms`/`api` workers - * itself. + * ASSETS binding (see `page-sources.ts`), plus `sections.json`, the id-keyed catalogue the + * bulk lookup filters. It does not serve the carousels' CONTENTS: each section names a + * client-side feed the client resolves against the `rooms`/`api` workers itself. * * Unauthenticated: every client gets the same layout, and the client fetches this before * anything player-specific. @@ -45,6 +51,57 @@ const app = new Hono() (c) => c.json({ service: 'discovery', status: 'ok' }) ) + // A set of sections looked up by id, out of the catalogue in `static/sections.json`. + // + // This is the id-keyed counterpart to the page-source route: a page source hands back a + // whole page's rows in draw order, while this hands back exactly the rows asked for, + // which is how the client refreshes sections it already knows the ids of without + // re-fetching every page they came from. + // + // The reference reads its catalogue file and filters it, so the failure modes are the + // file's, not the query's: an id matching nothing is simply absent from the answer + // rather than an error, and a query naming NO ids answers `[]` rather than the whole + // catalogue — the client asks for nothing when it wants nothing. Only a missing + // catalogue file is a 404. + .get( + '/sections/bulk', + describeRoute({ + tags: ['Discovery'], + summary: 'Look up sections by id', + description: [ + 'The sections named by the repeated `?id=` query, drawn from the catalogue in', + '`static/sections.json` — the union of the rows the page sources are built from.', + '', + 'The answer is that file FILTERED, which fixes the edges: rows come back in the', + 'catalogue’s order rather than the query’s, an id that matches nothing is left out', + 'instead of erroring, and repeating an id still yields it once. A query with no `id`', + 'at all answers `[]`. Rows are served exactly as stored, so a field this service', + 'doesn’t model survives the round trip.', + '', + 'Same section shape as `/sections/pagesource/{type}`: a section NAMES a feed', + '(`source`/`sourceMetadata`) that the client resolves itself. Nothing here is', + 'player-specific, so there is no auth.', + ].join('\n'), + parameters: [SECTION_IDS_PARAM], + responses: { + 200: json(DiscoverySections, 'The requested sections, in catalogue order'), + 404: { description: 'The catalogue file is not published' }, + }, + }), + async (c) => { + // `queries` and not `query`: the ids arrive as a repeated parameter, and `query` + // would collapse them to the first one and silently drop the rest of the page. + const ids = c.req.queries('id') + if (ids === undefined || ids.length === 0) return c.json([]) + + const sections = await readSections(c, SECTIONS_CATALOGUE) + if (sections === null) return c.notFound() + + const wanted = new Set(ids) + return c.json(sections.filter((s) => typeof s.id === 'string' && wanted.has(s.id))) + } + ) + // One discovery page's section layout, served verbatim from `static/.json`. .get( '/sections/pagesource/:type', @@ -104,6 +161,9 @@ app.get( 'than anything the code enumerates. Nothing is editable at runtime and every client', 'gets the same answer, so the routes are unauthenticated.', '', + 'Sections can also be fetched by id rather than by page: `/sections/bulk` filters', + '`static/sections.json`, the catalogue those layouts draw their rows from.', + '', 'A section names a feed rather than carrying its contents: the client resolves the', 'rooms, items and accounts behind each carousel against the `rooms` and `api` workers', 'itself.', diff --git a/apps/discovery/src/openapi.ts b/apps/discovery/src/openapi.ts index 2fd6ac3..849dda9 100644 --- a/apps/discovery/src/openapi.ts +++ b/apps/discovery/src/openapi.ts @@ -30,15 +30,42 @@ export const PAGE_SOURCE_PARAM: OpenAPIV3_1.ParameterObject = { description: [ 'The page source — `WatchHome`, `PlayHighlight`, `CommunityBoard`, `PlayMenuTabs`,', '`PlayCategories`, `StoreCategories`, `StoreFeatured`, `StoreClothing`,', - '`StoreConsumables`, `bulk` at the time of writing. It names a file in `static/`', + '`StoreConsumables` at the time of writing. It names a file in `static/`', '(`.json`) and is matched exactly, case included, so the set is whatever is', 'published rather than anything this worker enumerates.', + '', + '`sections` is a file in `static/` too but is not one of these: it is the id-keyed', + 'catalogue `/sections/bulk` filters, not a page anything draws.', ].join(' '), // Deliberately not an `enum`: the accepted values are the published files, and a spec // that froze today's list would be wrong the moment one is added. schema: { type: 'string', example: 'WatchHome' }, } +/** + * The repeated `?id=` query the bulk lookup selects on. Repetition, not a delimiter: the + * client sends `?id=A&id=B&id=C`, so this is `explode: true` form style rather than a + * single comma-joined value. + */ +export const SECTION_IDS_PARAM: OpenAPIV3_1.ParameterObject = { + name: 'id', + in: 'query', + required: false, + description: [ + 'A section id to look up, repeated once per section wanted. Ids that match nothing are', + 'skipped rather than erroring, and repeating one still yields it once — the answer is', + 'the catalogue filtered, so it can only ever be a subset of it. Omitting the parameter', + 'entirely answers `[]`.', + ].join(' '), + style: 'form', + explode: true, + schema: { type: 'array', items: { type: 'string' } }, + example: [ + 'Rooms_New_PlayHighlight_TabsTest_Explore', + 'RoomCategories_MoodPlaylists_FeelingLucky', + ], +} + // ---- Response schemas ------------------------------------------------------ /** `GET /` — the liveness probe body. */ @@ -82,7 +109,8 @@ export const DiscoverySection = z.object({ }) /** - * `GET /sections/pagesource/{type}` — a page's sections, in the order they are drawn. + * A list of sections — `GET /sections/pagesource/{type}` in the order a page draws them, + * or `GET /sections/bulk` in the catalogue's order. * * The DTO accepts anything (its validator is a no-op), but the STORE page builder is much * stricter and drops a section it doesn't like SILENTLY — no error reaches the client, the diff --git a/apps/discovery/src/page-sources.ts b/apps/discovery/src/page-sources.ts index b996594..fb2a3c0 100644 --- a/apps/discovery/src/page-sources.ts +++ b/apps/discovery/src/page-sources.ts @@ -35,3 +35,38 @@ export async function fetchPageSource(c: Context, type: string): Promise + +/** + * Read and parse `static/.json`. `null` when no such file is published. + * + * Unlike `fetchPageSource` this does NOT forward the caller's request. The body is needed + * here to filter, and forwarding would let a caller whose `If-None-Match` happens to match + * the FILE's etag get a bodiless 304 — wrong for a response that is a subset of the file + * rather than the file itself. + */ +export async function readSections(c: Context, name: string): Promise { + if (!SAFE_NAME.test(name)) return null + + const res = await c.env.ASSETS.fetch(new URL(`/${name}.json`, c.req.url)) + if (!res.ok) return null + + const rows: unknown = await res.json() + return Array.isArray(rows) ? (rows as SectionRow[]) : null +} diff --git a/apps/discovery/src/test/integration/api.test.ts b/apps/discovery/src/test/integration/api.test.ts index 6c447a9..b63f155 100644 --- a/apps/discovery/src/test/integration/api.test.ts +++ b/apps/discovery/src/test/integration/api.test.ts @@ -12,7 +12,6 @@ const PAGE_SOURCES = [ 'StoreFeatured', 'StoreClothing', 'StoreConsumables', - 'bulk', ] interface Section { @@ -152,12 +151,121 @@ describe('GET /sections/pagesource/:type', () => { }) }) +describe('GET /sections/bulk', () => { + /** Fetch a set of section ids and return the parsed rows. */ + async function bulk(ids: string[]) { + const query = ids.map((id) => `id=${encodeURIComponent(id)}`).join('&') + const res = await SELF.fetch(`https://discovery.example.com/sections/bulk?${query}`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/json') + return (await res.json()) as Section[] + } + + // The ids arrive as a REPEATED parameter, not a delimited one. Reading only the first + // would drop every row but one and leave the page nearly empty. + it('serves every id the query repeats', async () => { + const ids = [ + 'Rooms_New_PlayHighlight_TabsTest_Explore', + 'RoomCategories_MoodPlaylists_FeelingLucky', + 'Rooms_RecentlyUpdated_TabsTest_Explore', + 'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + ] + const sections = await bulk(ids) + expect(sections.map((s) => s.id)).toEqual(ids) + }) + + it('serves the catalogue rows verbatim', async () => { + const sections = await bulk(['RoomCategories_MoodPlaylists_FeelingLucky']) + expect(sections).toEqual([ + { + id: 'RoomCategories_MoodPlaylists_FeelingLucky', + // RoomCategoryListSection. + sectionType: 12, + sectionSubType: 'RoomCategories', + source: 'CuratedList', + sourceMetadata: 'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky', + displayMetadata: expect.stringContaining('"DisplayTitle":"I\'m Feeling Lucky"'), + }, + ]) + }) + + // The answer is the catalogue filtered, so it is ordered by the FILE and not by the + // query, and an id can only ever come back once however many times it is asked for. + it('answers in catalogue order regardless of the query order', async () => { + const sections = await bulk([ + 'Rooms_RecentlyUpdated_TabsTest_Explore', + 'Rooms_New_PlayHighlight_TabsTest_Explore', + ]) + expect(sections.map((s) => s.id)).toEqual([ + 'Rooms_New_PlayHighlight_TabsTest_Explore', + 'Rooms_RecentlyUpdated_TabsTest_Explore', + ]) + }) + + it('yields a repeated id once', async () => { + const sections = await bulk([ + 'Rooms_New_PlayHighlight_TabsTest_Explore', + 'Rooms_New_PlayHighlight_TabsTest_Explore', + ]) + expect(sections.map((s) => s.id)).toEqual(['Rooms_New_PlayHighlight_TabsTest_Explore']) + }) + + // An unknown id is left out rather than erroring: one stale id in a page's list must not + // take the rest of the page down with it. + it('skips ids that match nothing', async () => { + const sections = await bulk(['nope', 'Rooms_MyRooms_Play', 'also-nope']) + expect(sections.map((s) => s.id)).toEqual(['Rooms_MyRooms_Play']) + }) + + it('answers an empty array when no id matches', async () => { + expect(await bulk(['nope'])).toEqual([]) + }) + + // No ids asked for means nothing wanted — NOT the whole catalogue. + it('answers an empty array when the query names no ids', async () => { + const res = await SELF.fetch('https://discovery.example.com/sections/bulk') + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + // The catalogue is a file in `static/` like the layouts, so the page-source route reaches + // it too — `{type}` is the filename and nothing indexes which files are pages. Harmless + // and asserted so the overlap is a known fact rather than a surprise; the client asks for + // the catalogue through this route's `?id=` filter, never as a page. + it('is also reachable through the page-source route, unfiltered', async () => { + const res = await SELF.fetch('https://discovery.example.com/sections/pagesource/sections') + expect(res.status).toBe(200) + expect((await res.json()) as Section[]).toHaveLength(14) + }) + + // The response is a SUBSET of the file, so it must never be answered with the file's + // etag — a client that cached the file would otherwise be told its copy is still good. + it('ignores a conditional request matching the catalogue file', async () => { + const sections = await bulk(['Rooms_MyRooms_Play']) + expect(sections).toHaveLength(1) + + const res = await SELF.fetch( + 'https://discovery.example.com/sections/bulk?id=Rooms_MyRooms_Play', + { headers: { 'if-none-match': '"anything"' } } + ) + expect(res.status).toBe(200) + expect((await res.json()) as Section[]).toHaveLength(1) + }) +}) + describe('GET /openapi.json', () => { it('generates a spec with no dangling $refs', async () => { const res = await SELF.fetch('https://discovery.example.com/openapi.json') expect(res.status).toBe(200) const spec = (await res.json()) as { paths: Record } expect(Object.keys(spec.paths)).toContain('/sections/pagesource/{type}') + expect(Object.keys(spec.paths)).toContain('/sections/bulk') expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull() }) }) diff --git a/apps/discovery/static/bulk.json b/apps/discovery/static/bulk.json deleted file mode 100644 index c71cc5c..0000000 --- a/apps/discovery/static/bulk.json +++ /dev/null @@ -1,114 +0,0 @@ -[ - { - "id": "Rooms_New_PlayHighlight_TabsTest_Explore", - "sectionType": 0, - "sectionSubType": "Rooms_New", - "source": "Hot", - "sourceMetadata": "new", - "displayMetadata": "{\"DisplayTitle\":\"New\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\",\"unsupportedPlatforms\":[\"Switch\"]}" - }, - { - "id": "RoomCategories_MoodPlaylists_FeelingLucky", - "sectionType": 12, - "sectionSubType": "RoomCategories", - "source": "CuratedList", - "sourceMetadata": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky", - "displayMetadata": "{\"DisplayTitle\":\"I'm Feeling Lucky\",\"unsupportedPlatforms\":[\"Switch\",\"Pico\",\"Oculus\"], \"unsupportedInteractionCategories\":[\"VR\"]}" - }, - { - "id": "Rooms_RecentlyUpdated_TabsTest_Explore", - "sectionType": 0, - "sectionSubType": "Rooms_RecentlyUpdated", - "source": "CarouselEndpoint", - "sourceMetadata": "recentlyupdated", - "displayMetadata": "{\"DisplayTitle\":\"Recently Updated\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore", - "sectionType": 0, - "sectionSubType": "Rooms_Battle", - "source": "CarouselEndpoint", - "sourceMetadata": "battle_algoendpoint", - "displayMetadata": "{\"DisplayTitle\":\"Battle\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore", - "sectionType": 0, - "sectionSubType": "Rooms_Quests", - "source": "CarouselEndpoint", - "sourceMetadata": "quests_algoendpoint", - "displayMetadata": "{\"DisplayTitle\":\"Quests\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore", - "sectionType": 0, - "sectionSubType": "Rooms_Roleplay", - "source": "CarouselEndpoint", - "sourceMetadata": "roleplay_algoendpoint", - "displayMetadata": "{\"DisplayTitle\":\"Roleplay\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore", - "sectionType": 0, - "sectionSubType": "Rooms_Horror", - "source": "CarouselEndpoint", - "sourceMetadata": "horror_algoendpoint", - "displayMetadata": "{\"DisplayTitle\":\"Horror\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore", - "sectionType": 0, - "sectionSubType": "Rooms_Hangout", - "source": "CarouselEndpoint", - "sourceMetadata": "hangout_algoendpoint", - "displayMetadata": "{\"DisplayTitle\":\"Hangout\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore", - "sectionType": 0, - "sectionSubType": "Rooms_Casual", - "source": "CarouselEndpoint", - "sourceMetadata": "casual_algoendpoint", - "displayMetadata": "{\"DisplayTitle\":\"Casual\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore", - "sectionType": 0, - "sectionSubType": "Rooms_Exploration", - "source": "CarouselEndpoint", - "sourceMetadata": "explore_algoendpoint", - "displayMetadata": "{\"DisplayTitle\":\"Explore\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_ContinuePlaying_PlayLibrary", - "sectionType": 0, - "sectionSubType": "Rooms_ContinuePlaying", - "source": "Recent", - "sourceMetadata": null, - "displayMetadata": "{\"DisplayTitle\":\"Continue Playing\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_SavedForLater_PlayHighlight", - "sectionType": 0, - "sectionSubType": "Rooms_SavedForLater", - "source": "MyPlaylistByName", - "sourceMetadata": "__SavedForLater_Rooms", - "displayMetadata": "{\"DisplayTitle\":\"Saved for Later\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\", \"minItemsToShowSection\": 1}" - }, - { - "id": "Rooms_Favorites_PlayLibrary", - "sectionType": 0, - "sectionSubType": "Rooms_PlayerFavorites", - "source": "MyFavorites", - "sourceMetadata": null, - "displayMetadata": "{\"DisplayTitle\":\"Favorites\",\"supportsDedupe\":\"false\",\"horizontalScrollEnabled\":\"false\"}" - }, - { - "id": "Rooms_MyRooms_Play", - "sectionType": 0, - "sectionSubType": "myrooms", - "source": "MyCreatedRooms", - "sourceMetadata": null, - "displayMetadata": "{\"DisplayTitle\":\"My Rooms\"}" - } -] \ No newline at end of file diff --git a/apps/discovery/static/sections.json b/apps/discovery/static/sections.json new file mode 100644 index 0000000..8942a04 --- /dev/null +++ b/apps/discovery/static/sections.json @@ -0,0 +1,114 @@ +[ + { + "id": "Rooms_New_PlayHighlight_TabsTest_Explore", + "sectionType": 0, + "sectionSubType": "Rooms_New", + "source": "Hot", + "sourceMetadata": "new", + "displayMetadata": "{\"DisplayTitle\":\"New\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\",\"unsupportedPlatforms\":[\"Switch\"]}" + }, + { + "id": "RoomCategories_MoodPlaylists_FeelingLucky", + "sectionType": 12, + "sectionSubType": "RoomCategories", + "source": "CuratedList", + "sourceMetadata": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky", + "displayMetadata": "{\"DisplayTitle\":\"I'm Feeling Lucky\",\"unsupportedPlatforms\":[\"Switch\",\"Pico\",\"Oculus\"], \"unsupportedInteractionCategories\":[\"VR\"]}" + }, + { + "id": "Rooms_RecentlyUpdated_TabsTest_Explore", + "sectionType": 0, + "sectionSubType": "Rooms_RecentlyUpdated", + "source": "CarouselEndpoint", + "sourceMetadata": "recentlyupdated", + "displayMetadata": "{\"DisplayTitle\":\"Recently Updated\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "sectionType": 0, + "sectionSubType": "Rooms_Battle", + "source": "CarouselEndpoint", + "sourceMetadata": "battle_algoendpoint", + "displayMetadata": "{\"DisplayTitle\":\"Battle\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "sectionType": 0, + "sectionSubType": "Rooms_Quests", + "source": "CarouselEndpoint", + "sourceMetadata": "quests_algoendpoint", + "displayMetadata": "{\"DisplayTitle\":\"Quests\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "sectionType": 0, + "sectionSubType": "Rooms_Roleplay", + "source": "CarouselEndpoint", + "sourceMetadata": "roleplay_algoendpoint", + "displayMetadata": "{\"DisplayTitle\":\"Roleplay\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "sectionType": 0, + "sectionSubType": "Rooms_Horror", + "source": "CarouselEndpoint", + "sourceMetadata": "horror_algoendpoint", + "displayMetadata": "{\"DisplayTitle\":\"Horror\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "sectionType": 0, + "sectionSubType": "Rooms_Hangout", + "source": "CarouselEndpoint", + "sourceMetadata": "hangout_algoendpoint", + "displayMetadata": "{\"DisplayTitle\":\"Hangout\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "sectionType": 0, + "sectionSubType": "Rooms_Casual", + "source": "CarouselEndpoint", + "sourceMetadata": "casual_algoendpoint", + "displayMetadata": "{\"DisplayTitle\":\"Casual\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "sectionType": 0, + "sectionSubType": "Rooms_Exploration", + "source": "CarouselEndpoint", + "sourceMetadata": "explore_algoendpoint", + "displayMetadata": "{\"DisplayTitle\":\"Explore\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_ContinuePlaying_PlayLibrary", + "sectionType": 0, + "sectionSubType": "Rooms_ContinuePlaying", + "source": "Recent", + "sourceMetadata": null, + "displayMetadata": "{\"DisplayTitle\":\"Continue Playing\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_SavedForLater_PlayHighlight", + "sectionType": 0, + "sectionSubType": "Rooms_SavedForLater", + "source": "MyPlaylistByName", + "sourceMetadata": "__SavedForLater_Rooms", + "displayMetadata": "{\"DisplayTitle\":\"Saved for Later\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\", \"minItemsToShowSection\": 1}" + }, + { + "id": "Rooms_Favorites_PlayLibrary", + "sectionType": 0, + "sectionSubType": "Rooms_PlayerFavorites", + "source": "MyFavorites", + "sourceMetadata": null, + "displayMetadata": "{\"DisplayTitle\":\"Favorites\",\"supportsDedupe\":\"false\",\"horizontalScrollEnabled\":\"false\"}" + }, + { + "id": "Rooms_MyRooms_Play", + "sectionType": 0, + "sectionSubType": "myrooms", + "source": "MyCreatedRooms", + "sourceMetadata": null, + "displayMetadata": "{\"DisplayTitle\":\"My Rooms\"}" + } +] diff --git a/apps/lists/src/curated-lists.ts b/apps/lists/src/curated-lists.ts new file mode 100644 index 0000000..d7acd96 --- /dev/null +++ b/apps/lists/src/curated-lists.ts @@ -0,0 +1,144 @@ +import curatedLists from '../static/curated-lists.json' + +/** + * Which PAGE a curated list belongs to — the `Type` on the list and the `?type=` the client + * asks with. Not to be confused with the entity-type enum the algorithmic lists echo: this + * one names a page source (the Watch home, the store's Featured tab, …), and the list it + * keys is that page's row set. + * + * `None` is the client's unset sentinel; it never reaches the wire, and nothing is captured + * under it. + */ +export const CuratedListType = { + WatchHome: 0, + PlayHighlight: 1, + CommunityBoard: 2, + MobileHome: 3, + StoreFeatured: 4, + StoreClothing: 5, + StoreConsumables: 6, + PlayCategories: 7, + StoreInventions: 8, + TitleScreen: 9, + PlayMenuTabs: 10, + OrientationStoreFeatured: 11, + AppNavPortalPanel: 12, + WWPanelList: 13, + RecRoomPlusBenefits: 14, + RecCenterStorefront: 15, + RecCenterCommunityContent: 16, + None: 999, +} as const + +/** + * One curated list as the client parses it. `Description` may be null but `ImageName` must + * be a STRING — the client reads it straight into a string field — and `ItemIds` are + * strings even where they stand for numeric ids. + * + * `ListId` is a string HERE ONLY, and never reaches the client as one: see + * `serializeCuratedList`. `Accessibility` rides along from the captures untouched. + */ +export interface CuratedList { + ListId: string + CreatorAccountId: number + Name: string + Description: string | null + ImageName: string + Type: number + ItemIds: string[] + Accessibility?: number + CreatedAt: string +} + +/** + * Every list this server serves, all of them in `static/curated-lists.json` — drop a + * capture into that array and it is served; nothing else needs editing. (One file per list + * would read better, but wrangler bundles with esbuild, which has no glob import: a + * directory can only be picked up by naming every file in an `import`. `import.meta.glob` + * is a Vite feature — vitest runs through Vite and would resolve it, while the deployed + * build ships the call verbatim and throws at runtime, so tests would pass and the worker + * would not.) + * + * Nothing curates lists here, so these are static captures; ORDER matters only in that the + * first list of a given type is that page's default (see `BY_TYPE`). + * + * `ItemIds` are the discovery ROWS each page is built from (the section keys the `discovery` + * worker serves under `/sections/pagesource/*`), not room or item ids: the client resolves + * each row itself. + */ +const CURATED_LISTS: CuratedList[] = curatedLists + +/** + * A list's `ListId` is the reference's own, and those are 64-bit + * (`624765592684307326`) — past what a JS number holds exactly, so parsing one rounds it + * (…307326 → …307300). The captures therefore carry it as a STRING, which survives the + * round trip, and this puts the digits back on the wire unquoted: the client's field is a + * number, and a quoted id fails its parser. + * + * Only a run of digits is unquoted, so a malformed id is left alone rather than corrupting + * the JSON — the integration tests assert every capture has one. + */ +export function serializeCuratedList(list: CuratedList): string { + return JSON.stringify(list).replace(/"ListId":"(\d+)"/, '"ListId":$1') +} + +/** Names are matched case-insensitively — the casing that reaches us is the client's. */ +function nameKey(name: string): string { + return name.toLowerCase() +} + +/** All three keys the query carries. The most specific match wins. */ +const BY_CREATOR_TYPE_NAME = new Map() +/** Same list without the creator — the client sometimes asks with a creator nothing owns. */ +const BY_TYPE_NAME = new Map() +/** By name alone, for a name whose `type` doesn't line up with what it is captured under. */ +const BY_NAME = new Map() +/** + * By type alone: the page's DEFAULT list, which is what answers a `name` this server has + * nothing under — the store's Featured page, for one, asks for its rows by the reference's + * numeric list id (`name=17859340`) rather than by a name. + */ +const BY_TYPE = new Map() + +for (const list of CURATED_LISTS) { + const name = nameKey(list.Name) + BY_CREATOR_TYPE_NAME.set(`${list.CreatorAccountId}/${list.Type}/${name}`, list) + if (!BY_TYPE_NAME.has(`${list.Type}/${name}`)) BY_TYPE_NAME.set(`${list.Type}/${name}`, list) + if (!BY_NAME.has(name)) BY_NAME.set(name, list) + // First captured wins, so a page with more than one list defaults to whichever sits + // earliest in `static/curated-lists.json`. + if (!BY_TYPE.has(list.Type)) BY_TYPE.set(list.Type, list) +} + +/** + * What a request that matches nothing at all gets: the Play menu's Explore panel. An empty + * body — or a 404 — renders as a blank page rather than an error, so answering with + * SOMETHING is the better failure, and it is what the reference was observed doing for a + * list it had nothing under. Falls back to whatever sits first if that capture is ever + * dropped from the array. + */ +const DEFAULT_CURATED_LIST = + BY_NAME.get(nameKey('Discovery.PageSource.PlayExplore')) ?? CURATED_LISTS[0] + +/** + * The list behind `GET /curatedlists?creatorAccountId=&type=&name=`, resolved most-specific + * first and never empty. `type` is the page asking (see `CuratedListType`) and dictates the + * name, so the two normally agree; when they don't, the page's default list still answers. + */ +export function resolveCuratedList( + creatorAccountId: string | undefined, + type: string | undefined, + name: string | undefined +): CuratedList { + const key = nameKey(name ?? '') + const parsedType = Number.parseInt(type ?? '', 10) + const hasType = Number.isInteger(parsedType) + + return ( + (hasType ? BY_CREATOR_TYPE_NAME.get(`${creatorAccountId}/${parsedType}/${key}`) : undefined) ?? + (hasType ? BY_TYPE_NAME.get(`${parsedType}/${key}`) : undefined) ?? + BY_NAME.get(key) ?? + (hasType ? BY_TYPE.get(parsedType) : undefined) ?? + DEFAULT_CURATED_LIST + ) +} diff --git a/apps/lists/src/lists.app.ts b/apps/lists/src/lists.app.ts index 905604a..016a4dd 100644 --- a/apps/lists/src/lists.app.ts +++ b/apps/lists/src/lists.app.ts @@ -5,6 +5,8 @@ import { getHotRooms } from '@repo/domain' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +import { resolveCuratedList, serializeCuratedList } from './curated-lists' + import type { Context } from 'hono' import type { App } from './context' @@ -22,8 +24,10 @@ function unauthorized(c: Context) { } /** - * What the ids in a list ARE. One enum shared by the curated lists' `Type` and the - * algorithmic lists' — a BYTE on the client, so only 0–255 round-trips. + * What the ids in an ALGORITHMIC list are — a BYTE on the client, so only 0–255 + * round-trips. Not the curated lists' `Type`, which names the page a list belongs to + * (see `CuratedListType` in `curated-lists.ts`); the two are different enums that happen to + * share a field name. * * It is what tells the client which service to resolve the ids against, which is why the * algorithmic route echoes back the type it was asked for rather than asserting one of its @@ -45,64 +49,6 @@ const ListEntityType = { /** The largest value the client's byte-wide `Type` can carry back. */ const MAX_LIST_ENTITY_TYPE = 255 -/** - * The canned discovery pages served by `GET /curatedlists`, keyed by the `Name` the client - * asks for. `ItemIds` are discovery row keys (strings), `Description` is null but - * `ImageName` must be a string, and `CreatedAt` keeps its 7-digit fractional seconds — all - * as the client's parser expects them. - * - * `ListId` is ours to choose and is deliberately SMALL. The reference's ids are 64-bit - * (`624765592684307326`), past what a JS number holds exactly, so serving them verbatim - * meant carrying them as bigints and hand-writing the JSON to keep the digits — machinery - * for an id nothing on this server looks up. What the id has to be is stable and unique - * per page: the client caches a list against it, so two pages sharing one id would serve - * each other's rows from cache, and changing a page's id would drop its cache. Renumber - * only when the page's contents are meant to be re-fetched. - */ -const CURATED_LISTS = [ - { - ListId: 1, - CreatorAccountId: 1, - Name: 'Discovery.PageSource.PlayExplore', - Description: null, - ImageName: 'DefaultRoomImage.jpg', - Type: ListEntityType.DiscoverySection, - ItemIds: [ - 'Rooms_New_PlayHighlight_TabsTest_Explore', - 'RoomCategories_MoodPlaylists_FeelingLucky', - 'Rooms_RecentlyUpdated_TabsTest_Explore', - 'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - ], - Accessibility: 1, - CreatedAt: '2025-04-23T18:27:03.2643786Z', - }, - { - ListId: 2, - CreatorAccountId: 1, - Name: 'Discovery.PageSource.PlayLibrary', - Description: null, - ImageName: 'DefaultRoomImage.jpg', - Type: ListEntityType.DiscoverySection, - // The library page: what you were playing, saved, favorited and made. Note the - // second row is a `PlayHighlight` key rather than a `PlayLibrary` one — that is the - // reference's own naming, not a typo to tidy. - ItemIds: [ - 'Rooms_ContinuePlaying_PlayLibrary', - 'Rooms_SavedForLater_PlayHighlight', - 'Rooms_Favorites_PlayLibrary', - 'Rooms_MyRooms_Play', - ], - Accessibility: 1, - CreatedAt: '2025-04-23T18:25:31.5308539Z', - }, -] - /** * The entities an algorithmic list hands back (`GET /algorithmiclists/:list`) — ROOMS, which * is what a Play/Explore row is built from. Nothing ranks anything here yet, so one canned @@ -149,21 +95,6 @@ const HOT_LIST_FEED = 'community' */ const DEFAULT_ALGORITHMIC_LIST_TYPE = ListEntityType.Rooms -/** - * Each page keyed by its lowercased `Name` — the only thing a request decides is which one - * it gets. Each entry is an ARRAY of one list: the endpoint answers a collection, and the - * client reads it as such however many entries come back. - */ -const CURATED_LIST_PAGES = new Map(CURATED_LISTS.map((list) => [list.Name.toLowerCase(), [list]])) - -/** - * What a request that names no page — or names one nothing is captured for — gets: the - * Explore page. A 404 or an empty array renders as an empty Play page, so answering with - * SOMETHING is the better failure, and it is also what the reference was observed doing - * for a `name` this server has no list under. - */ -const DEFAULT_CURATED_LIST_PAGE = CURATED_LIST_PAGES.get('discovery.pagesource.playexplore')! - const app = new Hono() .use( '*', @@ -202,22 +133,35 @@ const app = new Hono() ]) }) - // The curated lists behind a discovery page (`GET /curatedlists`). The client asks with - // `?creatorAccountId=&type=&name=`, and `name` is the page it wants - // (`Discovery.PageSource.PlayExplore`, `…PlayLibrary`): it picks which canned page comes - // back, matched case-insensitively. Nothing curates lists here, so the pages are static - // captures — the same stand-in posture as `/curatedlists/bulk` above. + // The curated list behind a discovery page (`GET /curatedlists`). The client asks with + // `?creatorAccountId=&type=&name=` and reads back ONE list object — not a collection. + // `type` is the page asking (`CuratedListType`: the Watch home, the store's Featured tab, + // …) and dictates the name it asks under, so the two normally agree. // - // `creatorAccountId` and `type` are accepted and IGNORED, deliberately: the reference was - // observed answering a `type=5` request with a `Type` 7 list, so filtering on them would - // answer nothing where it answers a page. An unknown `name` falls back to Explore rather - // than an empty array, which renders as an empty Play page. + // Nothing curates lists here, so every list is a static capture in + // `static/curated-lists.json`. `resolveCuratedList` matches most-specific first and + // always answers something: a name this server has nothing under falls back to the page's + // default list rather than 404ing or answering empty, either of which renders as a blank + // page. That fallback is load-bearing — the store's Featured page asks for its rows by the + // reference's numeric list id (`name=17859340`), which is nothing's name here. // - // `ItemIds` are the discovery ROWS the page is built from (algorithm/section keys), not - // room ids — the client resolves each one itself. + // `ItemIds` are the discovery ROWS the page is built from (the section keys the `discovery` + // worker serves), not room or item ids — the client resolves each one itself. .get('/curatedlists', async (c) => { - const name = c.req.query('name') ?? '' - return c.json(CURATED_LIST_PAGES.get(name.toLowerCase()) ?? DEFAULT_CURATED_LIST_PAGE) + // Serialized by hand rather than through `c.json`: the reference's `ListId`s are + // 64-bit and are carried as strings so their digits survive being parsed — see + // `serializeCuratedList`, which puts them back on the wire as numbers. + return c.body( + serializeCuratedList( + resolveCuratedList( + c.req.query('creatorAccountId'), + c.req.query('type'), + c.req.query('name') + ) + ), + 200, + { 'content-type': 'application/json' } + ) }) // One discovery ROW's contents (`GET /algorithmiclists/:list?type=1`). `:list` is the row diff --git a/apps/lists/src/test/integration/api.test.ts b/apps/lists/src/test/integration/api.test.ts index 0c62969..ccee5d2 100644 --- a/apps/lists/src/test/integration/api.test.ts +++ b/apps/lists/src/test/integration/api.test.ts @@ -103,99 +103,118 @@ it('serves the canned curated-list bulk lookup', async () => { ]) }) -it('serves the canned discovery page from /curatedlists', async () => { +it('serves one curated list object, not a collection', async () => { + // The client reads a single list off this endpoint — a bare object, not an array. const res = await SELF.fetch( - `${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=RoomGenreTags` + `${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=Discovery.PageSource.PlayExplore` ) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('application/json') - const text = await res.text() - expect(JSON.parse(text)).toMatchObject([ - { - ListId: 1, - CreatorAccountId: 1, - Name: 'Discovery.PageSource.PlayExplore', - Description: null, - ImageName: 'DefaultRoomImage.jpg', - Type: 7, - Accessibility: 1, - CreatedAt: '2025-04-23T18:27:03.2643786Z', - ItemIds: [ - 'Rooms_New_PlayHighlight_TabsTest_Explore', - 'RoomCategories_MoodPlaylists_FeelingLucky', - 'Rooms_RecentlyUpdated_TabsTest_Explore', - 'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - 'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore', - ], - }, - ]) + const body = await res.text() + // The reference's list ids are 64-bit: they must reach the client as an unquoted number + // with every digit intact, which parsing them here would round away (…307326 → …307300). + expect(body).toContain('"ListId":624765592684307326') + + // Compared without the id: a literal here would round the same way the parse does, so + // the digits are checked on the raw body above and everything else on the object. + const { ListId: _id, ...rest } = JSON.parse(body) as Record + expect(rest).toEqual({ + CreatorAccountId: 1, + Name: 'Discovery.PageSource.PlayExplore', + Description: null, + ImageName: 'DefaultRoomImage.jpg', + Type: 7, + ItemIds: [ + 'Rooms_New_PlayHighlight_TabsTest_Explore', + 'RoomCategories_MoodPlaylists_FeelingLucky', + 'Rooms_RecentlyUpdated_TabsTest_Explore', + 'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + 'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore', + ], + Accessibility: 1, + CreatedAt: '2025-04-23T18:27:03.2643786Z', + }) }) -it('serves the PlayLibrary page when the query names it', async () => { - const res = await SELF.fetch( - `${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=Discovery.PageSource.PlayLibrary` - ) - expect(res.status).toBe(200) - - const text = await res.text() - expect(JSON.parse(text)).toMatchObject([ - { - ListId: 2, - CreatorAccountId: 1, - Name: 'Discovery.PageSource.PlayLibrary', - Description: null, - ImageName: 'DefaultRoomImage.jpg', - Type: 7, - Accessibility: 1, - CreatedAt: '2025-04-23T18:25:31.5308539Z', - ItemIds: [ - 'Rooms_ContinuePlaying_PlayLibrary', - 'Rooms_SavedForLater_PlayHighlight', - 'Rooms_Favorites_PlayLibrary', - 'Rooms_MyRooms_Play', - ], - }, - ]) - - // The name is matched case-insensitively, and `type` is not filtered on — the request - // above asks for type 5 and gets the type 7 list, as the reference does. - const lower = await SELF.fetch(`${ORIGIN}/curatedlists?name=discovery.pagesource.playlibrary`) - expect(await lower.text()).toBe(text) -}) - -it('gives each curated page a distinct, JS-safe ListId', async () => { - // The client caches a list against its ListId, so two pages must never share one — and - // an id past Number.MAX_SAFE_INTEGER would round on the way through JSON, which is why - // these are small numbers of our own rather than the reference's 64-bit ones. - const pages = ['Discovery.PageSource.PlayExplore', 'Discovery.PageSource.PlayLibrary'] - const ids: number[] = [] - for (const name of pages) { - const [list] = (await ( - await SELF.fetch(`${ORIGIN}/curatedlists?name=${name}`) - ).json()) as Array<{ ListId: number }> - expect(Number.isSafeInteger(list.ListId)).toBe(true) - ids.push(list.ListId) +it('serves every capture in static/curated-lists.json by name', async () => { + // One array holds every list, and each entry must be reachable by the keys the client + // asks with. `ImageName` must be a string even when empty — the client parses it into + // one — while `Description` may be null. The ids the client caches against have to be + // unique and reach it with their digits intact. + const names = [ + 'Discovery.PageSource.PlayExplore', + 'Discovery.PageSource.PlayLibrary', + 'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky', + ] + const seen = new Set() + for (const name of names) { + const res = await SELF.fetch(`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=${name}`) + expect(res.status).toBe(200) + const body = await res.text() + // Never a quoted id: the client's field is a number. + expect(body).toMatch(/"ListId":\d+,/) + const list = JSON.parse(body) as { + ListId: number + Type: number + Name: string + ItemIds: string[] + Description: string | null + ImageName: string + } + expect(list.Name).toBe(name) + expect(list.Type).toBe(7) + expect(list.ItemIds.length).toBeGreaterThan(0) + expect(typeof list.ImageName).toBe('string') + const id = /"ListId":(\d+),/.exec(body)?.[1] + expect(seen.has(id!)).toBe(false) + seen.add(id!) } - expect(new Set(ids).size).toBe(ids.length) }) -it('falls back to the Explore page for a name it has nothing for', async () => { - // An empty array renders as an empty Play page, so an unknown page source answers - // SOMETHING — which is also what the reference was observed doing for RoomGenreTags. +it('matches the name case-insensitively and prefers it over the type', async () => { + const canonical = await ( + await SELF.fetch( + `${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=Discovery.PageSource.PlayLibrary` + ) + ).text() + expect(canonical).toContain('"ListId":5321092632685904804') + expect(JSON.parse(canonical)).toMatchObject({ Name: 'Discovery.PageSource.PlayLibrary', Type: 7 }) + + // Casing reaching us is the client's, not ours. + const lower = await SELF.fetch(`${ORIGIN}/curatedlists?name=discovery.pagesource.playlibrary`) + expect(await lower.text()).toBe(canonical) + + // Every capture shares type 7, so the name is what tells them apart — a request naming + // Library must never come back with Explore's rows, and the type alone answers with the + // page default (the first list in the array). + const explore = await SELF.fetch(`${ORIGIN}/curatedlists?type=7`) + expect(((await explore.json()) as { Name: string }).Name).toBe('Discovery.PageSource.PlayExplore') +}) + +it('falls back to the default list for a name it has nothing under', async () => { + // The store's Featured page asks for its rows by the reference's numeric list id rather + // than by a name — nothing here is called that, and nothing is captured under its type + // either, so the default list answers. An empty body or a 404 would render as a blank + // page rather than an error. const explore = await ( await SELF.fetch(`${ORIGIN}/curatedlists?name=Discovery.PageSource.PlayExplore`) ).text() - for (const query of ['?type=99&name=Nope', '?creatorAccountId=1&type=5&name=RoomGenreTags', '']) { - const res = await SELF.fetch(`${ORIGIN}/curatedlists${query}`) - expect(res.status).toBe(200) - expect(await res.text()).toBe(explore) + for (const query of [ + '?creatorAccountId=1&type=4&name=17859340', + '?type=99&name=Nope', + '?creatorAccountId=7&type=&name=', + '', + ]) { + const bare = await SELF.fetch(`${ORIGIN}/curatedlists${query}`) + expect(bare.status).toBe(200) + expect(await bare.text()).toBe(explore) } }) diff --git a/apps/lists/static/curated-lists.json b/apps/lists/static/curated-lists.json new file mode 100644 index 0000000..c5e2fc0 --- /dev/null +++ b/apps/lists/static/curated-lists.json @@ -0,0 +1,58 @@ +[ + { + "ListId": "624765592684307326", + "CreatorAccountId": 1, + "Name": "Discovery.PageSource.PlayExplore", + "Description": null, + "ImageName": "DefaultRoomImage.jpg", + "Type": 7, + "ItemIds": [ + "Rooms_New_PlayHighlight_TabsTest_Explore", + "RoomCategories_MoodPlaylists_FeelingLucky", + "Rooms_RecentlyUpdated_TabsTest_Explore", + "Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore", + "Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore" + ], + "Accessibility": 1, + "CreatedAt": "2025-04-23T18:27:03.2643786Z" + }, + { + "ListId": "5321092632685904804", + "CreatorAccountId": 1, + "Name": "Discovery.PageSource.PlayLibrary", + "Description": null, + "ImageName": "DefaultRoomImage.jpg", + "Type": 7, + "ItemIds": [ + "Rooms_ContinuePlaying_PlayLibrary", + "Rooms_SavedForLater_PlayHighlight", + "Rooms_Favorites_PlayLibrary", + "Rooms_MyRooms_Play" + ], + "Accessibility": 1, + "CreatedAt": "2025-04-23T18:25:31.5308539Z" + }, + { + "ListId": "8578579969342570774", + "CreatorAccountId": 1, + "Name": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky", + "Description": "I'm Feeling Lucky", + "ImageName": "DefaultRoomImage.jpg", + "Type": 7, + "ItemIds": [ + "action_algorithmiclist_roomcategory", + "hangout_roomcategory_card", + "horror_roomcategory_card", + "pvp_roomcategory_card", + "quests_roomcategory_card", + "roleplay_roomcategory_card" + ], + "Accessibility": 1, + "CreatedAt": "2024-05-22T05:37:43.7726633Z" + } +]