diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 4d044aa..97157fd 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -82,6 +82,7 @@ import { form, GameRewardRequest, InfluencerIdsResponse, + InfluencerTierResponse, json, JsonArray, jsonBody, @@ -357,6 +358,13 @@ async function pushBalancePurchase( } } +/** + * The influencer partner tier every account has here — the "not an influencer" one. It is + * the whole body of both `/api/influencerpartnerprogram/influencer` and `…/myinfluencer`, + * served as a bare number rather than wrapped in anything. + */ +const NOT_AN_INFLUENCER = 0 + /** The operator-granted role that comes with a complimentary subscription. */ const DEVELOPER_ROLE = 'developer' @@ -3233,27 +3241,26 @@ const app = new Hono({ strict: false }) } ) - // Whether the caller is in the influencer partner program. NOBODY is: this server runs - // no such program, and "not an influencer" is a 404 rather than a body saying so — the - // reference answers 404 with an EMPTY body typed `application/json`, which is what the - // client branches on. A 200 carrying null or `{}` is a different answer to it. + // One account's standing in the influencer partner program. NOBODY here has one: this + // server runs no such program, so the answer is the literal `0` — the "not an influencer" + // tier — for every account. // - // Deliberately built by hand rather than through `c.notFound()`: the worker's not-found - // handler answers its own body, and this has to be empty with that content type. + // A BARE NUMBER is the whole body, like `…/makerai/checkfreetrialeligibility`'s bare + // `false`, not a number wrapped in an object. This used to answer 404 with an empty body; + // the tier is what the client actually reads. // - // `accountId` is accepted and ignored — the reference binds it and never reads it, the - // answer being the same for everyone. The token is still validated first, so an - // unauthenticated caller gets 401 rather than the 404. + // `accountId` names the account being asked about. It makes no difference to the answer + // while nobody is an influencer, but it is read rather than ignored so this stays the + // question it looks like — the caller's own standing is `…/myinfluencer` below. .get( '/api/influencerpartnerprogram/influencer', describeRoute({ tags: ['Econ'], - summary: 'The caller’s influencer partner program status', + summary: 'An account’s influencer partner program tier', description: [ - 'Always 404 with an EMPTY body typed `application/json` — this server runs no partner', - 'program, and 404 is how the reference says “not an influencer”. `accountId` is', - 'accepted and ignored; the answer is the same for every caller. Auth is checked first,', - 'so a missing or invalid token is 401, not 404.', + 'The partner tier of the account named by `accountId`, as a BARE NUMBER — the whole', + 'body is `0`, not an object around it. Always 0: this server runs no partner program,', + 'so no account is an influencer. Auth-gated; a missing or invalid token is a 401.', ].join(' '), security: AUTHED, parameters: [ @@ -3261,19 +3268,47 @@ const app = new Hono({ strict: false }) name: 'accountId', in: 'query', required: false, - description: 'The account being asked about. Accepted and ignored.', + description: 'The account being asked about. Every account answers 0.', schema: { type: 'integer' }, }, ], responses: { - 404: { description: 'Not in the partner program — always. Empty body' }, + 200: json(InfluencerTierResponse, 'The account’s tier — always 0'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.body('', 404, { 'Content-Type': 'application/json' }) + return c.json(NOT_AN_INFLUENCER) + } + ) + + // The same question about the CALLER — the `my` form, which names no account because the + // token already does. Same bare `0`, for the same reason: nobody here is an influencer. + // + // Its own route rather than an alias of the one above, because the two differ in who they + // are about; they agree today only because the answer is currently the same for everyone. + .get( + '/api/influencerpartnerprogram/myinfluencer', + describeRoute({ + tags: ['Econ'], + summary: 'The caller’s influencer partner program tier', + description: [ + 'The caller’s own partner tier — the `my` form of the route above, taking the account', + 'from the token rather than a query parameter. A BARE NUMBER, always `0`: this server', + 'runs no partner program. Auth-gated; a missing or invalid token is a 401.', + ].join(' '), + security: AUTHED, + responses: { + 200: json(InfluencerTierResponse, 'The caller’s tier — always 0'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json(NOT_AN_INFLUENCER) } ) diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 7488354..b18284e 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -226,6 +226,18 @@ export const InfluencerIdsResponse = z.object({ .describe('Account ids in the partner program. Empty — no programme runs here'), }) +/** + * `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — one account's + * standing in the partner program. + * + * A BARE NUMBER, not an object: the body is the literal `0`, which is the "not an + * influencer" tier. Nobody on this server is one, so 0 is the answer for every account, the + * caller's own included. + */ +export const InfluencerTierResponse = z + .literal(0) + .describe('The account’s partner tier. Always 0 — nobody here is an influencer') + /** * `GET /api/incentivizedreferrals/progress` — how far the caller has got with the * refer-a-friend rewards: how many referrals have been verified, and which rewards they diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index a718d1a..d19be2a 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -2448,9 +2448,8 @@ describe('econ endpoints', () => { { headers: await bearer('207') } ) expect(res.status).toBe(200) - // An object around the list, not a bare array. Note this is a 200 while its - // single-account sibling below answers 404 — "nobody is" is a complete answer to - // "who is?", where "are you?" is answered by the 404 itself. + // An object around the list, not a bare array — unlike its single-account siblings + // below, whose whole body is a bare tier number. expect(await res.json()).toEqual({ InfluencerIds: [] }) }) @@ -2459,24 +2458,43 @@ describe('econ endpoints', () => { expect(res.status).toBe(401) }) - test('GET /api/influencerpartnerprogram/influencer 404s with an empty JSON body', async () => { + test('GET /api/influencerpartnerprogram/influencer answers a bare 0', async () => { const res = await exports.default.fetch( + `${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=220`, + { headers: await bearer('206') } + ) + expect(res.status).toBe(200) + // The tier is the WHOLE body — a bare number, not `{ Tier: 0 }` or a string. 0 is + // "not an influencer", which every account is here. + expect(res.headers.get('content-type')).toContain('application/json') + expect(await res.text()).toBe('0') + + // Any account, the caller's own included, gets the same answer. + const self = await exports.default.fetch( `${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=206`, { headers: await bearer('206') } ) - // 404 IS the answer — "not an influencer" — and the body is empty, not `{}` or null, - // with the content type the reference sends. - expect(res.status).toBe(404) - expect(res.headers.get('content-type')).toContain('application/json') - expect(await res.text()).toBe('') + expect(await self.json()).toBe(0) }) - test('GET /api/influencerpartnerprogram/influencer 401s without a bearer token', async () => { - // Auth is checked before the 404, so an unauthenticated caller is told that, not that - // they aren't an influencer. - const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/influencer`) - expect(res.status).toBe(401) - expect(await res.text()).toBe('') + test('GET /api/influencerpartnerprogram/myinfluencer answers a bare 0', async () => { + // The `my` form takes the account from the token instead of a query parameter, and + // answers the same tier in the same shape. + const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/myinfluencer`, { + headers: await bearer('206'), + }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('0') + }) + + test('the influencer tier routes 401 without a bearer token', async () => { + // Auth is checked before anything is answered, so an unauthenticated caller is told + // that rather than handed a tier. + for (const path of ['influencer', 'myinfluencer']) { + const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/${path}`) + expect(res.status, path).toBe(401) + expect(await res.text()).toBe('') + } }) test('GET /api/makerai/checkfreetrialeligibility answers a bare false', async () => { @@ -2592,6 +2610,7 @@ describe('econ endpoints', () => { 'GET /api/incentivizedreferrals/progress', 'GET /api/influencerpartnerprogram/influencer', 'GET /api/influencerpartnerprogram/influencers', + 'GET /api/influencerpartnerprogram/myinfluencer', 'GET /api/itemWishlists/v1/wishlist/me', 'GET /api/itemWishlists/v1/wishlist/{accountId}', 'GET /api/makerai/checkfreetrialeligibility',