From a2946fb9f49b46a95db19c5cf0d531eef623b705 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 8 Sep 2026 23:07:27 -0400 Subject: [PATCH] [api] fix saved outfits not appearing --- apps/api/src/routes/avatar.ts | 36 +++++++++++---- apps/api/src/test/integration/api.test.ts | 55 ++++++++++++++++++++--- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 4d97966..06bc76e 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -4,6 +4,7 @@ import { describeRoute } from 'hono-openapi' import { CURRENT_OUTFIT_SLOT, getOutfit, + getOutfits, getOutfitsByAccounts, inventionDescriptionRejection, inventionLongDescriptionRejection, @@ -1021,29 +1022,46 @@ export const avatarRoutes = new Hono({ strict: false }) } ) - // The caller's outfit wardrobe. An empty list for now — the outfits saved through - // `PUT /outfits/me` are in the shared `outfit` table already, but which of them - // belong in this list (and in what shape) has not been pinned down, so it answers [] - // rather than guessing. + // The caller's outfit wardrobe — every slot they have saved, ordered by slot. The same + // read as `econ`'s `GET /api/avatar/v3/saved`, on the bare path the newer client uses: + // both worker's write paths land in the shared `outfit` table, so both list endpoints + // serve the same rows. + // + // Slot 0 is INCLUDED. It is the outfit being worn (what `/outfits/me` reads), but it is + // also a saved slot: the newer client picks the slot it saves into (`/api/avatar/v4/saved/set` + // 400s without one), so filtering slot 0 out would hide a real saved outfit whenever a + // wardrobe entry lands there. Showing the worn outfit as a wardrobe entry is the cheaper + // mistake of the two. + // + // Rows are served exactly as they were stored, unprojected — see the note atop + // `outfits-db.ts`: econ's saved slots hold the old flat PascalCase outfit while + // `/outfits/me` holds the newer envelope, and neither is converted into the other. .get( '/outfits/me/saved', describeRoute({ tags: ['Avatar', '2025'], summary: 'The caller’s saved outfits', description: - 'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' + - 'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' + - 'list should carry, and in what shape, is not pinned down yet.', + 'The wardrobe behind the newer outfit screen: every slot the caller has saved, ' + + 'ordered by slot, and `[]` when they have saved none. The same rows `econ`’s ' + + '`GET /api/avatar/v3/saved` serves — both write paths land in the shared `outfit` ' + + 'table.\n\n' + + 'Slot 0 is included. It is the outfit being worn (what `GET /outfits/me` reads) but ' + + 'it is a saved slot too, and the client chooses the slot it saves into, so omitting ' + + 'it would hide a real outfit whenever a wardrobe entry lands there.\n\n' + + 'Each outfit is served exactly as it was stored, unprojected: slots written through ' + + '`PUT /outfits/me` hold the newer envelope while `econ`’s saved-set slots hold the ' + + 'old flat shape, and neither is converted into the other.', security: AUTHED, responses: { - 200: json(JsonArray, 'An empty list'), + 200: json(JsonArray, 'The saved outfits, ordered by slot (empty when none)'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.json([]) + return c.json(await getOutfits(c.env.DB, id)) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 7f3d15c..51493b4 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1566,15 +1566,58 @@ describe('public endpoints', () => { expect(((await worn.json()) as { Name: string | null }).Name).toBe(null) }) - test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => { + test('GET /outfits/me/saved lists every saved slot, ordered by slot', async () => { const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`) expect(anon.status).toBe(401) - // Empty even for account 42, which saved an outfit through PUT /outfits/me above. - const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, { - headers: await bearer(), + + // A distinct account, so this doesn't depend on what the tests above saved for 42. + const saved = async (sub: string) => { + const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, { + headers: await bearer(sub), + }) + expect(res.status).toBe(200) + return (await res.json()) as Array> + } + + // A player who has never saved gets [], not the empty-outfit envelope `/outfits/me` + // serves — an empty wardrobe is an empty list. + expect(await saved('4242')).toEqual([]) + + const outfit = (slot: number, name: string | null) => ({ + DataVersion: 2, + LegacyData: { + SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0', + SelectionsV2: '{"selections":[]}', + FaceFeatures: '{"ver":7}', + SkinColor: 'Dc6StLFk60u5iUTrb3_C3w', + HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg', + }, + CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}', + Selections: [], + Slot: slot, + Name: name, + Accessibility: 1, + ThumbnailFileName: null, }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) + + // Saved out of order, to prove the list is ordered by slot rather than by write time. + for (const [slot, name] of [ + [2, 'two'], + [0, null], + ] as Array<[number, string | null]>) { + await exports.default.fetch(`${ORIGIN}/outfits/me`, { + method: 'PUT', + headers: { ...(await bearer('4242')), 'content-type': 'application/json' }, + body: JSON.stringify(outfit(slot, name)), + }) + } + + // Slot 0 is in the list: it is the outfit being worn, but it is a saved slot too, and + // the client picks the slot it writes. Each row comes back verbatim. + expect(await saved('4242')).toEqual([outfit(0, null), outfit(2, 'two')]) + + // Another account's wardrobe is its own. + expect(await saved('4343')).toEqual([]) }) test('POST /outfits/bulk serves each account’s worn outfit, keyed by id', async () => {