[econ] add the POST equipment update endpoint

This commit is contained in:
Devin Zuczek
2026-08-25 16:36:20 -04:00
parent 7275176734
commit c5421539c3
3 changed files with 82 additions and 21 deletions
+11 -4
View File
@@ -2070,11 +2070,17 @@ const app = new Hono<App>({ strict: false })
} }
) )
// Favourite/un-favourite owned equipment. [Authorize]. The client PUTs the entries // Favourite/un-favourite owned equipment. [Authorize]. The client sends the entries it
// it wants changed (one request can carry several) and reads nothing back. Only // 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 // `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. // 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', '/api/equipment/v1/update',
describeRoute({ describeRoute({
tags: ['Equipment'], tags: ['Equipment'],
@@ -2082,7 +2088,8 @@ const app = new Hono<App>({ strict: false })
description: [ description: [
'Applies the posted `Favorited` flags to the callers owned equipment, matched by', 'Applies the posted `Favorited` flags to the callers owned equipment, matched by',
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller', '`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
'doesnt own is silently skipped. Empty body on success.', 'doesnt own is silently skipped. Empty body on success. Accepts PUT or POST — the',
'client uses both, with the same body.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'), requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
+3 -3
View File
@@ -36,7 +36,7 @@ export const EQUIPMENT_SCHEMA_DDL: string[] = [
* inconsistency to tidy up: a drop is a flat record holding avatar, consumable and * 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 * 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 * 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<string, unknown> { export interface Equipment extends Record<string, unknown> {
ModificationGuid: string ModificationGuid: string
@@ -46,7 +46,7 @@ export interface Equipment extends Record<string, unknown> {
Rarity: number Rarity: number
/** Always -1 (all platforms) — we don't gate equipment per platform. */ /** Always -1 (all platforms) — we don't gate equipment per platform. */
PlatformMask: number 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 Favorited: boolean
} }
@@ -73,7 +73,7 @@ export async function grantEquipment(
.run() .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 { export interface EquipmentFavoriteUpdate {
ModificationGuid: string ModificationGuid: string
Favorited: boolean Favorited: boolean
+68 -14
View File
@@ -1094,9 +1094,9 @@ describe('econ endpoints', () => {
expect(await unlocked()).toHaveLength(1) expect(await unlocked()).toHaveLength(1)
// Favouriting sticks. // Favouriting sticks.
const update = async (favorited: boolean) => const update = async (favorited: boolean, method: 'PUT' | 'POST' = 'PUT') =>
exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT', method,
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' }, headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
body: JSON.stringify([ body: JSON.stringify([
{ PrefabName: '[DiscGolfDisc]', ModificationGuid: guid, Favorited: favorited }, { PrefabName: '[DiscGolfDisc]', ModificationGuid: guid, Favorited: favorited },
@@ -1113,22 +1113,75 @@ describe('econ endpoints', () => {
expect((await update(false)).status).toBe(200) expect((await update(false)).status).toBe(200)
after = await unlocked() after = await unlocked()
expect(after[0].Favorited).toBe(false) 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 () => { test('POST /api/equipment/v1/update favourites from the clients own body', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, { // The body verbatim as the client sends it — a full echo of the entry it was served,
method: 'PUT', // of which only `Favorited` is read.
headers: { 'Content-Type': 'application/json' }, const post = async (favorited: boolean, sub = '33') =>
body: '[]', exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
}) method: 'POST',
expect(anon.status).toBe(401) 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`, { // Nothing owned yet: the guid matches no row, so this is a silent no-op, not an error.
method: 'PUT', expect((await post(true)).status).toBe(200)
headers: { ...(await bearer('32')), 'Content-Type': 'application/json' },
body: '{}', 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 () => { 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/v1/complete',
'POST /api/checklist/v2/complete', 'POST /api/checklist/v2/complete',
'POST /api/consumables/v1/consume', 'POST /api/consumables/v1/consume',
'POST /api/equipment/v1/update',
'POST /api/gamerewards/v1/request', 'POST /api/gamerewards/v1/request',
'POST /api/items/bulkpurchase', 'POST /api/items/bulkpurchase',
'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/cleargroup',