From bfe88a983759b179aef4fec09e3e3e1e9d44a538 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 2 Oct 2026 13:56:17 -0400 Subject: [PATCH] [inventions] cheer endpoint --- apps/api/src/routes/avatar.ts | 43 ++++++++++++++++++++++ apps/api/src/test/integration/api.test.ts | 45 +++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 4083657..80009e5 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -1897,6 +1897,49 @@ export const avatarRoutes = new Hono({ strict: false }) return c.json({ success: true, error: '' }) } ) + // The newer client's cheer: the same body as `v1/cheer` and the same write, at a new + // path. It answers the `{ Value, Success, Error, error_id }` envelope the other v2+ + // invention routes use, refusals in-band — an ASSUMPTION: only the request has been + // observed, not what the client reads off the reply. + .post( + '/api/inventions/v2/cheer', + describeRoute({ + tags: ['Inventions'], + summary: 'Cheer or un-cheer an invention (v2)', + description: + '`v1/cheer` for the newer client: persists the caller’s cheer state and resyncs ' + + 'the invention’s `CheerCount`; repeating the same state is idempotent. Answers ' + + 'the `{ Value, Success, Error, error_id }` envelope with `Value` null, and a ' + + 'refusal — a bad body, an unknown invention — is `Success: false` with a message ' + + 'rather than an error status.', + security: AUTHED, + requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'), + responses: { + 200: json(InventionDeleteResult, 'The envelope, `Value` null either way'), + 401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'), + }, + }), + async (c) => { + const playerId = await authedId(c) + if (playerId === null) return c.json(inventionDeleteResult('Unauthorized'), 401) + const body = await c.req + .json<{ InventionId?: unknown; Cheer?: unknown }>() + .catch(() => ({}) as Record) + const inventionId = body.InventionId + if ( + typeof inventionId !== 'number' || + !Number.isInteger(inventionId) || + typeof body.Cheer !== 'boolean' + ) { + return c.json(inventionDeleteResult('InventionId and Cheer are required')) + } + if ((await getInventionById(c.env.DB, inventionId)) === null) { + return c.json(inventionDeleteResult('No such invention')) + } + await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer) + return c.json(inventionDeleteResult()) + } + ) // Report an invention. Stored in the `report` table the player and event reports use — // same fields, same moderation life — with `invention_id` set. See // migrations/0016_report_invention.sql. diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 9e8eaf5..b31c43f 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -3914,6 +3914,50 @@ describe('public endpoints', () => { expect(noId.status).toBe(400) }) + test('POST /api/inventions/v2/cheer is v1/cheer in the newer client’s envelope', async () => { + const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('8210')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Cheerable Sofa', inventionDataFilename: 'cheerable2.inv' }), + }) + const { InventionId } = ((await saved.json()) as InventionSaveResult).Invention + const cheer = async (body: unknown, sub?: string) => + exports.default.fetch(`${ORIGIN}/api/inventions/v2/cheer`, { + method: 'POST', + headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const stored = async (): Promise => + ( + (await ( + await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=${InventionId}`) + ).json()) as SavedInvention + ).CheerCount + const ok = { Value: null, Success: true, Error: null, error_id: null } + + const anonymous = await cheer({ InventionId, Cheer: true }) + expect(anonymous.status).toBe(401) + expect(await anonymous.json()).toMatchObject({ Value: null, Success: false }) + + expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok) + expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok) + expect(await stored()).toBe(1) + const personal = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/personaldetails/${InventionId}`, + { headers: await bearer('44') } + ) + expect(await personal.json()).toEqual({ IsCheering: true }) + + expect(await (await cheer({ InventionId, Cheer: false }, '44')).json()).toEqual(ok) + expect(await stored()).toBe(0) + + // Refusals stay in the envelope, at 200. + const unknown = await cheer({ InventionId: 999999, Cheer: true }, '44') + expect(unknown.status).toBe(200) + expect(await unknown.json()).toMatchObject({ Success: false, Error: 'No such invention' }) + expect(await (await cheer({ InventionId }, '44')).json()).toMatchObject({ Success: false }) + }) + test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => { const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { method: 'POST', @@ -10059,6 +10103,7 @@ describe('openapi', () => { 'POST /api/inventions/v1/settags', 'POST /api/inventions/v1/update', 'POST /api/inventions/v1/updateprice', + 'POST /api/inventions/v2/cheer', 'POST /api/inventions/v2/delete', 'POST /api/inventions/v2/unpublish', 'POST /api/inventions/v2/update',