[2026] fix a few wire shapes

This commit is contained in:
Devin Zuczek
2026-10-13 12:13:40 -04:00
parent bf817977ec
commit 6d4156bd67
6 changed files with 85 additions and 18 deletions
+22
View File
@@ -297,6 +297,28 @@ export const progressionRoutes = new Hono<App>({ strict: false })
}),
async (c) => c.json(await getReputation(c.env.DB, Number.parseInt(c.req.param('id'), 10)))
)
// How much XP the caller has earned today — what the client measures against its daily
// XP cap. A BARE INTEGER (the client decodes a plain `int`), not a progression record.
//
// Registered BEFORE `/progression/:id` below, which otherwise takes `xpEarnedToday` as
// an account id and answers `{ PlayerId: null, Level: 1, XP: 0 }` — the client's decoder
// threw on that (`expected:'Number Token', actual:'{'`).
//
// STUB: always 0. Nothing here records when XP was earned, only the running total. No
// auth while the answer is the same for everybody; it will need the caller once it is
// real, since the path names no player.
.get(
'/api/players/v1/progression/xpEarnedToday',
describeRoute({
tags: ['Progression'],
summary: 'XP the caller earned today (stub)',
description:
'The XP earned so far today, as a bare integer. Always `0` — XP is banked as a ' +
'running total and nothing records when it was earned.',
responses: { 200: json(BareInteger, 'A bare `0`') },
}),
(c) => c.json(0)
)
.get(
'/api/players/v1/progression/:id',
describeRoute({
@@ -2081,6 +2081,14 @@ describe('public endpoints', () => {
expect(res.status).toBe(400)
})
test('GET /api/players/v1/progression/xpEarnedToday is a bare 0', async () => {
// A plain int on the client. It must not fall through to `/progression/:id`, which
// would read the path segment as an account id and answer a progression OBJECT.
const res = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/xpEarnedToday`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
})
test('GET /api/progressionEvents/active is a bare 18 (no auth)', async () => {
// A hardcoded stub: the id of an archived event (0 would mean "no active event").
// A 404 would stall the client's load instead.
@@ -10208,6 +10216,7 @@ describe('openapi', () => {
'GET /api/playerevents/v1/{eventId}/responses',
'GET /api/playerevents/v2/{eventId}',
'GET /api/players/v1/playerPhotoTaggingSetting',
'GET /api/players/v1/progression/xpEarnedToday',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/progressionEvents/active',
@@ -48,12 +48,13 @@ const app = new Hono<App>()
return c.json([])
})
// Sampling configuration, asked for once per session (`?sessionId=<guid>`). An empty
// object carries no per-event overrides, which the client reads as "sample everything
// at the built-in default rates". Since the events are discarded anyway, the rate it
// picks makes no difference here.
// Sampling configuration, asked for once per session (`?sessionId=<guid>`). A BARE ARRAY
// of `{ EventType, ShouldBeSampled }` entries (the client decodes a `List<EventDTO>`),
// e.g. `[{ "EventType": "player_join", "ShouldBeSampled": true }]` — not an object: the
// `{}` this once served made the client's decoder throw. Empty says "sample nothing",
// which is the right answer since the events are discarded anyway.
.get('/sampling', async (c) => {
return c.json({})
return c.json([])
})
export default app
@@ -49,7 +49,7 @@ it('acknowledges an unreadable body on both event paths', async () => {
it('serves an empty sampling configuration', async () => {
const res = await SELF.fetch(`${ORIGIN}/sampling?sessionId=e9a899ce-ce46-447d-bb22-11e82ed68f8d`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
expect(await res.json()).toEqual([])
})
// The client always sends `sessionId`, but nothing here reads it, so a missing one must
@@ -57,5 +57,5 @@ it('serves an empty sampling configuration', async () => {
it('serves the sampling configuration without a sessionId', async () => {
const res = await SELF.fetch(`${ORIGIN}/sampling`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
expect(await res.json()).toEqual([])
})
@@ -96,14 +96,24 @@ const app = new Hono<App>()
})
})
// The caller's CRM configuration — the campaign/messaging settings the client fetches on
// startup before it will ask about anything else here. An object, not an envelope.
// The caller's CRM configuration — which CRM (Braze, Rengage) and push service the
// client is assigned, fetched on startup before it will ask about anything else here.
//
// STUB: there is no CRM behind this, so the config is EMPTY rather than invented. An
// empty object is the honest answer and the client reads it as "nothing configured";
// made-up keys would switch on messaging paths that have nothing to serve them. Unlike
// `/config/categories` there is no visible text to mark as a stub — nothing here is
// rendered — so the marker stays in this comment.
// ENVELOPED: the client decodes a `RecNetResultObject<CrmAssignmentDTO>` and reads
// `Value` off it itself, so the `{ Value, Success, Error, error_id }` wrapper is on the
// wire — this once served a bare `{}`, which leaves `Value` null. Note the casing:
// PascalCase `Success`/`Error` beside a snake_case `error_id`.
//
// `Value` has exactly two keys. `UserConfig.AssignedCrmType` is the `CrmType` enum (None
// 0, Braze 1, Rengage 2) and `AllowPush` a bool — both NON-nullable on the client, so
// never null. `AssignedPushNotificationService` is a nullable `PushNotificationServices`
// (APNS 0, Firebase 1, Mock 2, Braze 3, Rengage 4). `BrazeConfig` is `{ AppId,
// SdkEndpoint }` or null.
//
// STUB: there is no CRM behind this, so the answer is "none assigned" — type 0, no push
// service, push off, no Braze config — rather than an invented assignment that would
// switch on messaging paths with nothing to serve them. Nothing here is rendered, so
// the stub marker stays in this comment.
//
// Auth-gated because the route is `me`-scoped, like `/preferences` below: the config
// belongs to the caller even while the answer is the same for everyone.
@@ -111,7 +121,19 @@ const app = new Hono<App>()
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
return c.json({})
return c.json({
Value: {
UserConfig: {
AssignedCrmType: 0,
AssignedPushNotificationService: null,
AllowPush: false,
},
BrazeConfig: null,
},
Success: true,
Error: null,
error_id: null,
})
})
// The caller's own notification preferences — which categories they have muted, by
@@ -123,9 +123,22 @@ it('serves the caller’s CRM config', async () => {
const res = await SELF.fetch(`${ORIGIN}/crm/me/config/v3`, { headers: await bearer('205') })
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
// Empty rather than invented: there is no CRM behind this, and made-up keys would switch
// on client messaging paths with nothing to serve them.
expect(await res.json()).toEqual({})
// A RecNetResultObject envelope — the client reads `Value` off it. "No CRM assigned"
// rather than invented: there is no CRM behind this. `AssignedCrmType` and `AllowPush`
// are non-nullable on the client, so they must never be null.
expect(await res.json()).toEqual({
Value: {
UserConfig: {
AssignedCrmType: 0,
AssignedPushNotificationService: null,
AllowPush: false,
},
BrazeConfig: null,
},
Success: true,
Error: null,
error_id: null,
})
})
it('401s the CRM config read without a bearer token', async () => {