From 2a41b93c1317c2d48b14c82c55460487efe83186 Mon Sep 17 00:00:00 2001 From: dukedotdev Date: Mon, 12 Oct 2026 14:19:23 -0400 Subject: [PATCH] [econ] Fix three 2026 response contracts (#125) * [econ] Wrap 2026 room offer purchase counts in a result envelope * [econ] Include the built-in identity flag on owned 2026 consumables * [econ] Return the 2026 empty subscription season DTO --- apps/econ/src/econ.app.ts | 74 +++++++++++++++++++--- apps/econ/src/test/integration/api.test.ts | 41 ++++++++++++ 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index fcb0425..3a934b2 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' +import { z } from 'zod' import { addXp, @@ -4720,8 +4721,30 @@ const app = new Hono({ strict: false }) ) .get( '/econ/roomOffer/room/:roomId/purchaseCounts', - listRoute('Per-offer purchase counts for a room', 'Empty stub'), - (c) => c.json([]) + describeRoute({ + summary: 'Per-offer purchase counts for a room', + responses: { + 200: json( + z.union([ + JsonArray, + z.object({ + Value: JsonArray, + Success: z.boolean(), + Error: z.string().nullable(), + error_id: z.string().nullable(), + }), + ]), + '2026: RecNet result envelope; older builds: a list' + ), + }, + }), + async (c) => { + if (((await authedBuild(c)) ?? 0) >= 20260323) { + // The 2026 client decodes RecNetResultListObject. + return c.json({ Value: [], Success: true, Error: null, error_id: null }) + } + return c.json([]) + } ) .get( '/econ/roomGiftDropShops/room/:roomId', @@ -4901,7 +4924,14 @@ const app = new Hono({ strict: false }) async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.json(await getConsumables(c.env.DB, id)) + const consumables = await getConsumables(c.env.DB, id) + // These rows are purchases of built-in store items. The 2026 client + // includes this flag in the instance key and verifies it against the definition. + return c.json( + ((await authedBuild(c)) ?? 0) >= 20260323 + ? consumables.map((item) => ({ ...item, IsRRO: true })) + : consumables + ) } ) @@ -6638,13 +6668,41 @@ const app = new Hono({ strict: false }) } ) - // The subscription seasons running right now (the RR+ seasonal reward tracks). Nothing - // here runs a season, so this is an empty-list stub — the client reads it as "no season - // in progress" and skips the seasonal UI, where a 404 stalls the RR+ page load. + // The 2026 decoder requires an object; RecNet rejects JSON null as an empty response. .get( '/api/subscriptionseasons/v1/seasons/current', - listRoute('Current subscription seasons', 'Empty stub — no RR+ season is running'), - (c) => c.json([]) + describeRoute({ + summary: 'Current subscription season', + responses: { + 200: json( + z.union([ + JsonArray, + z.object({ + SubscriptionSeasonId: z.string().uuid(), + Name: z.string(), + ImageName: z.string(), + StartAt: z.string(), + NextSeasonStart: z.string().nullable(), + Milestones: JsonArray, + }), + ]), + '2026: season object without milestones; older builds: []' + ), + }, + }), + async (c) => + c.json( + ((await authedBuild(c)) ?? 0) >= 20260323 + ? { + SubscriptionSeasonId: '00000000-0000-0000-0000-000000000000', + Name: '', + ImageName: '', + StartAt: '0001-01-01T00:00:00Z', + NextSeasonStart: null, + Milestones: [], + } + : [] + ) ) // Whether the caller can start a Maker AI free trial. Always false, mirroring the diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index c97c904..bed7ef7 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -3338,6 +3338,14 @@ describe('econ endpoints', () => { expect(await shop(92)).toEqual([]) }) + test('2026 room-offer purchase counts use the captured result envelope', async () => { + const res = await exports.default.fetch(`${ORIGIN}/econ/roomOffer/room/92/purchaseCounts`, { + headers: await bearer('42', undefined, '20260323.14'), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Value: [], Success: true, Error: null, error_id: null }) + }) + test('POST /api/ugcPurchasables/v1/items/bulk resolves custom avatar items, echoing RoomId', async () => { const item = await createCustomAvatarItem(env.DB, { customAvatarItemId: crypto.randomUUID(), @@ -3705,6 +3713,24 @@ describe('econ endpoints', () => { expect(bad.status).toBe(400) }) + test('2026 owned built-in consumables include the definition identity flag', async () => { + await grantConsumable(env.DB, 9731, 'test-built-in', 3) + try { + for (const version of ['20260323.14', '20250718.01']) { + const res = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, { + headers: await bearer('9731', undefined, version), + }) + expect(res.status).toBe(200) + const [item] = (await res.json()) as Array> + expect(item.Count).toBe(3) + if (version === '20260323.14') expect(item.IsRRO).toBe(true) + else expect(item).not.toHaveProperty('IsRRO') + } + } finally { + await env.DB.prepare('DELETE FROM consumable WHERE account_id = ?').bind(9731).run() + } + }) + test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`) expect(anon.status).toBe(401) @@ -8256,3 +8282,18 @@ describe('discord role gift', () => { expect(await getPendingGifts(env.DB, 9322)).toHaveLength(1) }) }) + +test('2026 current subscription season supplies a nonempty disabled-season DTO', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/subscriptionseasons/v1/seasons/current`, { + headers: await bearer('42', undefined, '20260323.14'), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + SubscriptionSeasonId: '00000000-0000-0000-0000-000000000000', + Name: '', + ImageName: '', + StartAt: '0001-01-01T00:00:00Z', + NextSeasonStart: null, + Milestones: [], + }) +})