diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 8335792..db9d7ee 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -2070,11 +2070,17 @@ const app = new Hono({ strict: false }) } ) - // Favourite/un-favourite owned equipment. [Authorize]. The client PUTs the entries - // it wants changed (one request can carry several) and reads nothing back. Only + // Favourite/un-favourite owned equipment. [Authorize]. The client sends the entries it + // wants changed (one request can carry several) and reads nothing back. Only // `Favorited` is written — the rest of each entry is the client echoing what it was // served, and a guid the caller doesn't own matches no row and is dropped. - .put( + // + // PUT or POST: the client uses both spellings for this one call, with an identical body + // either way, so they are the same route rather than two handlers. A 404 on the POST + // leaves the star drawn on the item the client already redrew, and the favourite + // silently doesn't stick. + .on( + ['PUT', 'POST'], '/api/equipment/v1/update', describeRoute({ tags: ['Equipment'], @@ -2082,7 +2088,8 @@ const app = new Hono({ strict: false }) description: [ 'Applies the posted `Favorited` flags to the caller’s owned equipment, matched by', '`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller', - 'doesn’t own is silently skipped. Empty body on success.', + 'doesn’t own is silently skipped. Empty body on success. Accepts PUT or POST — the', + 'client uses both, with the same body.', ].join(' '), security: AUTHED, requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'), diff --git a/apps/econ/src/equipment-db.ts b/apps/econ/src/equipment-db.ts index ffadc72..8830531 100644 --- a/apps/econ/src/equipment-db.ts +++ b/apps/econ/src/equipment-db.ts @@ -36,7 +36,7 @@ export const EQUIPMENT_SCHEMA_DDL: string[] = [ * inconsistency to tidy up: a drop is a flat record holding avatar, consumable and * equipment fields side by side, so it needs the prefix to disambiguate, while this * record is all equipment. Confirmed against the live endpoint, and the entries the - * client PUTs back to `/api/equipment/v1/update` use the same unprefixed names. + * client sends back to `/api/equipment/v1/update` use the same unprefixed names. */ export interface Equipment extends Record { ModificationGuid: string @@ -46,7 +46,7 @@ export interface Equipment extends Record { Rarity: number /** Always -1 (all platforms) — we don't gate equipment per platform. */ PlatformMask: number - /** Player-set favourite flag, toggled by `PUT /api/equipment/v1/update`. */ + /** Player-set favourite flag, toggled by `PUT`/`POST /api/equipment/v1/update`. */ Favorited: boolean } @@ -73,7 +73,7 @@ export async function grantEquipment( .run() } -/** One entry of the `PUT /api/equipment/v1/update` body. */ +/** One entry of the `PUT`/`POST /api/equipment/v1/update` body. */ export interface EquipmentFavoriteUpdate { ModificationGuid: string Favorited: boolean diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 9c300ac..38cfda3 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1094,9 +1094,9 @@ describe('econ endpoints', () => { expect(await unlocked()).toHaveLength(1) // Favouriting sticks. - const update = async (favorited: boolean) => + const update = async (favorited: boolean, method: 'PUT' | 'POST' = 'PUT') => exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { - method: 'PUT', + method, headers: { ...(await bearer('31')), 'Content-Type': 'application/json' }, body: JSON.stringify([ { PrefabName: '[DiscGolfDisc]', ModificationGuid: guid, Favorited: favorited }, @@ -1113,22 +1113,75 @@ describe('econ endpoints', () => { expect((await update(false)).status).toBe(200) after = await unlocked() expect(after[0].Favorited).toBe(false) + + // The client sends this as a POST too, with the same body — same effect. + expect((await update(true, 'POST')).status).toBe(200) + expect((await unlocked())[0]?.Favorited).toBe(true) + expect((await update(false, 'POST')).status).toBe(200) + expect((await unlocked())[0]?.Favorited).toBe(false) }) - test('PUT /api/equipment/v1/update 401s without a token, 400s on a non-array body', async () => { - const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: '[]', - }) - expect(anon.status).toBe(401) + test('POST /api/equipment/v1/update favourites from the client’s own body', async () => { + // The body verbatim as the client sends it — a full echo of the entry it was served, + // of which only `Favorited` is read. + const post = async (favorited: boolean, sub = '33') => + exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + body: JSON.stringify([ + { + PrefabName: '[ShareCamera]', + ModificationGuid: 'g5u0weNLmkCLeUXFUVn74Q', + FriendlyName: 'Camera Skin (Comic)', + Tooltip: 'ShareCamera Comic Debug: 2121', + Rarity: 5, + Favorited: favorited, + }, + ]), + }) - const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { - method: 'PUT', - headers: { ...(await bearer('32')), 'Content-Type': 'application/json' }, - body: '{}', + // Nothing owned yet: the guid matches no row, so this is a silent no-op, not an error. + expect((await post(true)).status).toBe(200) + + await grantEquipment(env.DB, 33, { + PrefabName: '[ShareCamera]', + ModificationGuid: 'g5u0weNLmkCLeUXFUVn74Q', + FriendlyName: 'Camera Skin (Comic)', + Tooltip: 'ShareCamera Comic Debug: 2121', + Rarity: 5, + PlatformMask: -1, + Favorited: false, }) - expect(bad.status).toBe(400) + const owned = async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, { + headers: await bearer('33'), + }) + return (await res.json()) as Array<{ ModificationGuid: string; Favorited: boolean }> + } + expect((await owned())[0]?.Favorited).toBe(false) + + expect((await post(true)).status).toBe(200) + expect((await owned())[0]?.Favorited).toBe(true) + expect((await post(false)).status).toBe(200) + expect((await owned())[0]?.Favorited).toBe(false) + }) + + test('equipment/v1/update 401s without a token, 400s on a non-array body (PUT and POST)', async () => { + for (const method of ['PUT', 'POST'] as const) { + const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: '[]', + }) + expect(anon.status).toBe(401) + + const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { + method, + headers: { ...(await bearer('32')), 'Content-Type': 'application/json' }, + body: '{}', + }) + expect(bad.status).toBe(400) + } }) test('POST /api/storefronts/v2/buyItem 409s when the sent price no longer matches', async () => { @@ -2770,6 +2823,7 @@ describe('econ endpoints', () => { 'POST /api/checklist/v1/complete', 'POST /api/checklist/v2/complete', 'POST /api/consumables/v1/consume', + 'POST /api/equipment/v1/update', 'POST /api/gamerewards/v1/request', 'POST /api/items/bulkpurchase', 'POST /api/objectives/v1/cleargroup',