mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] influencer endpoints for fun
This commit is contained in:
+123
-30
@@ -18,6 +18,7 @@ import {
|
||||
listInfluencerIds,
|
||||
ownsInvention,
|
||||
setOutfit,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt'
|
||||
@@ -113,7 +114,6 @@ import {
|
||||
form,
|
||||
GameRewardRequest,
|
||||
InfluencerIdsResponse,
|
||||
InfluencerTierResponse,
|
||||
IsOnWishlistBulkRequest,
|
||||
IsOnWishlistBulkResponse,
|
||||
ItemPurchaseInfoList,
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
PurchaseRoomKeyWithCurrencyRequest,
|
||||
PurchaseRoomKeyWithCurrencyResponse,
|
||||
ReferralProgressResponse,
|
||||
RemoveInfluencerBody,
|
||||
RoomConsumableDto,
|
||||
RoomConsumableEnvelope,
|
||||
RoomCurrencyDto,
|
||||
@@ -148,6 +149,9 @@ import {
|
||||
SaveOutfitRequest,
|
||||
SaveOutfitV4Response,
|
||||
SubscriptionResponse,
|
||||
SupportedInfluencerResponse,
|
||||
SupportInfluencerBody,
|
||||
SupportInfluencerResponse,
|
||||
UgcPurchasableBulkRequest,
|
||||
UgcPurchasableItemList,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
@@ -732,11 +736,17 @@ 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.
|
||||
* The influencer an account supports in the partner program, as its reads serve it — the
|
||||
* `supportedInfluencerId` on the account, or 0 for nobody (and for an account that does not
|
||||
* exist, which supports nobody too). The whole body of `/api/influencerpartnerprogram/influencer`,
|
||||
* `…/myinfluencer`, served as a bare number rather than wrapped in anything; the `…/support`
|
||||
* and `…/remove` writes answer a status envelope instead. The stored id is served as-is: it was an influencer when picked, and a later
|
||||
* revoke of their flag drops them from `…/influencers` but not from their supporters.
|
||||
*/
|
||||
const NOT_AN_INFLUENCER = 0
|
||||
async function supportedInfluencer(db: D1Database, accountId: number): Promise<number> {
|
||||
const account = await getAccount(db, accountId)
|
||||
return account?.supportedInfluencerId ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by
|
||||
@@ -6746,74 +6756,157 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// 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.
|
||||
// The influencer one account SUPPORTS in the partner program, by account id — every
|
||||
// player picks one (`…/support` below), and this is how anyone reads anyone's pick.
|
||||
//
|
||||
// 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` 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.
|
||||
// `false`, not a number wrapped in an object; 0 is "supports nobody", which is also what
|
||||
// an account that does not exist answers. The caller's own pick is `…/myinfluencer`.
|
||||
.get(
|
||||
'/api/influencerpartnerprogram/influencer',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'An account’s influencer partner program tier',
|
||||
summary: 'The influencer an account supports',
|
||||
description: [
|
||||
'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.',
|
||||
'The account id of the influencer the account named by `accountId` supports, as a',
|
||||
'BARE NUMBER — the whole body is the id, not an object around it; `0` when they',
|
||||
'support nobody (or no such account exists). Auth-gated; a missing or invalid token',
|
||||
'is a 401. A missing or non-numeric `accountId` is a 400.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The account being asked about. Every account answers 0.',
|
||||
required: true,
|
||||
description: 'The account being asked about.',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(InfluencerTierResponse, 'The account’s tier — always 0'),
|
||||
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
|
||||
400: { description: '`accountId` missing or not an integer (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(NOT_AN_INFLUENCER)
|
||||
const accountId = Number.parseInt(c.req.query('accountId') ?? '', 10)
|
||||
if (!Number.isInteger(accountId)) return c.body(null, 400)
|
||||
return c.json(await supportedInfluencer(c.env.DB, accountId))
|
||||
}
|
||||
)
|
||||
|
||||
// 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.
|
||||
// token already does. Same bare number, same 0 for nobody.
|
||||
//
|
||||
// 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.
|
||||
// are about.
|
||||
.get(
|
||||
'/api/influencerpartnerprogram/myinfluencer',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'The caller’s influencer partner program tier',
|
||||
summary: 'The influencer the caller supports',
|
||||
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.',
|
||||
'The account id of the influencer the caller supports — the `my` form of the route',
|
||||
'above, taking the account from the token rather than a query parameter. A BARE',
|
||||
'NUMBER; `0` when they support nobody. Auth-gated; a missing or invalid token is a 401.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(InfluencerTierResponse, 'The caller’s tier — always 0'),
|
||||
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(NOT_AN_INFLUENCER)
|
||||
return c.json(await supportedInfluencer(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Pick the influencer the caller supports: `influencerAccountId=<id>`, form-encoded. ONE
|
||||
// per player, stored on the account as `supportedInfluencerId`; posting again replaces
|
||||
// the pick. The id has to name an account currently flagged `isInfluencer` — anything
|
||||
// else, the caller's own id included, is a 400 and changes nothing. The reply is a bare
|
||||
// `{ Success, Error, error_id }` status envelope — no `Value`: the client reads the pick
|
||||
// back from `…/myinfluencer` rather than from this reply.
|
||||
.post(
|
||||
'/api/influencerpartnerprogram/support',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Pick the influencer the caller supports',
|
||||
description: [
|
||||
'Set the ONE influencer the caller supports in the partner program, replacing any',
|
||||
'earlier pick. `influencerAccountId` must name an account that is currently an',
|
||||
'influencer (listed by `…/influencers`); the caller cannot support themselves. The',
|
||||
'reply is a `{ Success, Error, error_id }` status envelope with no `Value`; read the',
|
||||
'new pick back from `…/myinfluencer`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(SupportInfluencerBody, 'The influencer to support'),
|
||||
responses: {
|
||||
200: json(SupportInfluencerResponse, 'The pick was recorded'),
|
||||
400: {
|
||||
description:
|
||||
'`influencerAccountId` missing, not an integer, the caller’s own id, or not an influencer (empty body)',
|
||||
},
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const raw = body.influencerAccountId
|
||||
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
|
||||
if (!Number.isInteger(influencerId) || influencerId === id) return c.body(null, 400)
|
||||
const influencer = await getAccount(c.env.DB, influencerId)
|
||||
if (influencer?.isInfluencer !== true) return c.body(null, 400)
|
||||
await updateAccount(c.env.DB, id, { supportedInfluencerId: influencerId })
|
||||
return c.json({ Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
// Stop supporting an influencer: `influencerAccountId=<id>`, form-encoded, the same body
|
||||
// as `…/support`. The field is UNSET on the account (dropped from the JSON, not written
|
||||
// as 0), so `…/myinfluencer` goes back to 0. The id has to be an integer (else 400), but
|
||||
// it only has to NAME THE CURRENT PICK to do anything: naming anyone else — a stale pick
|
||||
// from before a `…/support` that replaced it, or nobody at all — is a no-op that still
|
||||
// answers success, like a wishlist remove of something not listed. Guarding on the match
|
||||
// keeps a stale client from clearing a pick it never saw.
|
||||
.post(
|
||||
'/api/influencerpartnerprogram/remove',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Stop supporting an influencer',
|
||||
description: [
|
||||
'Clear the influencer the caller supports in the partner program. `influencerAccountId`',
|
||||
'names the current pick; the field is unset and `…/myinfluencer` answers 0 again.',
|
||||
'An id that is not the current pick changes nothing and still answers success. The',
|
||||
'reply is the same `{ Success, Error, error_id }` status envelope as `…/support`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(RemoveInfluencerBody, 'The influencer to stop supporting'),
|
||||
responses: {
|
||||
200: json(SupportInfluencerResponse, 'The pick was cleared (or was not this one)'),
|
||||
400: { description: '`influencerAccountId` missing or not an integer (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const raw = body.influencerAccountId
|
||||
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
|
||||
if (!Number.isInteger(influencerId)) return c.body(null, 400)
|
||||
if ((await supportedInfluencer(c.env.DB, id)) === influencerId) {
|
||||
// `undefined` wins the spread in updateAccount and JSON.stringify drops the key.
|
||||
await updateAccount(c.env.DB, id, { supportedInfluencerId: undefined })
|
||||
}
|
||||
return c.json({ Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -235,16 +235,42 @@ export const InfluencerIdsResponse = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — one account's
|
||||
* standing in the partner program.
|
||||
* `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — the account id of
|
||||
* the influencer an account supports.
|
||||
*
|
||||
* 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.
|
||||
* A BARE NUMBER, not an object: the body is the literal id, or `0` when the account
|
||||
* supports nobody. The write (`POST …/support`) does NOT answer in this shape; see
|
||||
* `SupportInfluencerResponse`.
|
||||
*/
|
||||
export const InfluencerTierResponse = z
|
||||
.literal(0)
|
||||
.describe('The account’s partner tier. Always 0 — nobody here is an influencer')
|
||||
export const SupportedInfluencerResponse = z
|
||||
.int()
|
||||
.describe('The account id of the influencer this account supports; 0 for nobody')
|
||||
|
||||
/** `POST /api/influencerpartnerprogram/support` — the influencer the caller picks. */
|
||||
export const SupportInfluencerBody = z.object({
|
||||
influencerAccountId: z
|
||||
.int()
|
||||
.describe('The account id of the influencer to support. Must be flagged `isInfluencer`'),
|
||||
})
|
||||
|
||||
/** `POST /api/influencerpartnerprogram/remove` — the influencer the caller stops supporting. */
|
||||
export const RemoveInfluencerBody = z.object({
|
||||
influencerAccountId: z
|
||||
.int()
|
||||
.describe('The account id of the influencer to stop supporting — the caller’s current pick'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The reply to `POST /api/influencerpartnerprogram/support` and `…/remove` — a bare
|
||||
* `{ Success, Error, error_id }` status envelope with NO `Value`: the pick is read back from
|
||||
* `…/myinfluencer`, not from this reply. The same mixed casing as `RoomCurrencyEnvelope`
|
||||
* (PascalCase `Success`/`Error` beside a lowercase `error_id`), minus the payload.
|
||||
*/
|
||||
export const SupportInfluencerResponse = z.object({
|
||||
Success: z.boolean().describe('Always true — every refusal is an empty 400 instead'),
|
||||
Error: z.null().describe('Always null'),
|
||||
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/incentivizedreferrals/progress` — how far the caller has got with the
|
||||
|
||||
@@ -9,6 +9,7 @@ import { exports } from 'cloudflare:workers'
|
||||
import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
getAccount,
|
||||
getOwnedInventionIds,
|
||||
getPendingGifts,
|
||||
getProgression,
|
||||
@@ -6944,43 +6945,164 @@ describe('econ endpoints', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
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') }
|
||||
)
|
||||
test('POST /api/influencerpartnerprogram/support picks the ONE influencer the caller supports', async () => {
|
||||
const seed = async (accountId: number, isInfluencer: boolean) => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId, username: `Support${accountId}`, isInfluencer }))
|
||||
.run()
|
||||
}
|
||||
await seed(8201, true)
|
||||
await seed(8202, true)
|
||||
await seed(8203, false) // a player, not an influencer
|
||||
await seed(8204, false) // the supporter
|
||||
const support = async (body: string, sub = '8204') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/support`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const my = async (sub = '8204') => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/influencerpartnerprogram/myinfluencer`,
|
||||
{
|
||||
headers: await bearer(sub),
|
||||
}
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
// Nobody picked yet: a bare 0, the whole body.
|
||||
expect(await my()).toBe('0')
|
||||
|
||||
// The raw form body the client posts. The reply is a bare status envelope — no `Value`;
|
||||
// the pick is read back from `…/myinfluencer`.
|
||||
const res = await support('influencerAccountId=8201')
|
||||
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')
|
||||
expect(await res.json()).toEqual({ Success: true, Error: null, error_id: null })
|
||||
expect(await my()).toBe('8201')
|
||||
expect((await getAccount(env.DB, 8204))?.supportedInfluencerId).toBe(8201)
|
||||
|
||||
// Any account, the caller's own included, gets the same answer.
|
||||
const self = await exports.default.fetch(
|
||||
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=206`,
|
||||
// One pick per player: posting again REPLACES it.
|
||||
expect((await support('influencerAccountId=8202')).status).toBe(200)
|
||||
expect(await my()).toBe('8202')
|
||||
|
||||
// Only an influencer can be supported, and not oneself. Each refusal is a 400 that
|
||||
// leaves the pick alone.
|
||||
expect((await support('influencerAccountId=8203')).status).toBe(400) // not an influencer
|
||||
expect((await support('influencerAccountId=99999999')).status).toBe(400) // no such account
|
||||
expect((await support('influencerAccountId=8204')).status).toBe(400) // oneself
|
||||
expect((await support('influencerAccountId=8201', '8201')).status).toBe(400) // an influencer, oneself
|
||||
expect((await support('influencerAccountId=nope')).status).toBe(400)
|
||||
expect((await support('')).status).toBe(400)
|
||||
expect(await my()).toBe('8202')
|
||||
|
||||
// Anyone can read anyone's pick, as the same bare number.
|
||||
const theirs = await exports.default.fetch(
|
||||
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=8204`,
|
||||
{ headers: await bearer('206') }
|
||||
)
|
||||
expect(await self.json()).toBe(0)
|
||||
expect(theirs.status).toBe(200)
|
||||
expect(await theirs.text()).toBe('8202')
|
||||
})
|
||||
|
||||
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'),
|
||||
})
|
||||
test('POST /api/influencerpartnerprogram/remove unsets the pick it names', async () => {
|
||||
for (const [accountId, isInfluencer] of [
|
||||
[8211, true],
|
||||
[8212, true],
|
||||
[8213, false],
|
||||
] as const) {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId, username: `Remove${accountId}`, isInfluencer }))
|
||||
.run()
|
||||
}
|
||||
const post = async (path: 'support' | 'remove', body: string, sub = '8213') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const my = async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/influencerpartnerprogram/myinfluencer`,
|
||||
{ headers: await bearer('8213') }
|
||||
)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
expect((await post('support', 'influencerAccountId=8211')).status).toBe(200)
|
||||
expect(await my()).toBe('8211')
|
||||
|
||||
// Naming someone other than the current pick is a no-op success: the pick stays.
|
||||
const other = await post('remove', 'influencerAccountId=8212')
|
||||
expect(other.status).toBe(200)
|
||||
expect(await other.json()).toEqual({ Success: true, Error: null, error_id: null })
|
||||
expect(await my()).toBe('8211')
|
||||
|
||||
// Naming the current pick clears it: the same envelope, and `…/myinfluencer` is 0
|
||||
// again. The field is DROPPED from the account, not written as 0.
|
||||
const res = await post('remove', 'influencerAccountId=8211')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('0')
|
||||
expect(res.headers.get('content-type')).toContain('application/json')
|
||||
expect(await res.json()).toEqual({ Success: true, Error: null, error_id: null })
|
||||
expect(await my()).toBe('0')
|
||||
const account = await getAccount(env.DB, 8213)
|
||||
expect(account).not.toBeNull()
|
||||
expect('supportedInfluencerId' in account!).toBe(false)
|
||||
|
||||
// Removing with nothing picked is also a no-op success.
|
||||
expect((await post('remove', 'influencerAccountId=8211')).status).toBe(200)
|
||||
expect(await my()).toBe('0')
|
||||
|
||||
// A body that names no integer is a 400.
|
||||
expect((await post('remove', 'influencerAccountId=nope')).status).toBe(400)
|
||||
expect((await post('remove', '')).status).toBe(400)
|
||||
|
||||
// Unauthenticated is a 401.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: 'influencerAccountId=8211',
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('the influencer tier routes 401 without a bearer token', async () => {
|
||||
test('GET /api/influencerpartnerprogram/influencer answers 0 for nobody, 400 for no accountId', async () => {
|
||||
// An account that has picked nobody, and one that does not exist, both support nobody.
|
||||
for (const accountId of ['206', '99999999']) {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=${accountId}`,
|
||||
{ headers: await bearer('206') }
|
||||
)
|
||||
expect(res.status, accountId).toBe(200)
|
||||
// The id is the WHOLE body — a bare number, not `{ Id: 0 }` or a string.
|
||||
expect(res.headers.get('content-type')).toContain('application/json')
|
||||
expect(await res.text()).toBe('0')
|
||||
}
|
||||
for (const query of ['', '?accountId=', '?accountId=abc']) {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/influencerpartnerprogram/influencer${query}`,
|
||||
{ headers: await bearer('206') }
|
||||
)
|
||||
expect(res.status, query).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
test('the influencer support 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']) {
|
||||
// that rather than handed an id.
|
||||
for (const path of ['influencer?accountId=206', 'myinfluencer']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/${path}`)
|
||||
expect(res.status, path).toBe(401)
|
||||
expect(await res.text()).toBe('')
|
||||
}
|
||||
const post = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/support`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: 'influencerAccountId=8201',
|
||||
})
|
||||
expect(post.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/makerai/checkfreetrialeligibility answers a bare false', async () => {
|
||||
@@ -7187,6 +7309,8 @@ describe('econ endpoints', () => {
|
||||
'POST /api/consumables/v1/updateActive',
|
||||
'POST /api/equipment/v1/update',
|
||||
'POST /api/gamerewards/v1/request',
|
||||
'POST /api/influencerpartnerprogram/remove',
|
||||
'POST /api/influencerpartnerprogram/support',
|
||||
'POST /api/itemWishlists/v1/isonwishlist/bulk',
|
||||
'POST /api/itemWishlists/v1/wishlist/add',
|
||||
'POST /api/itemWishlists/v1/wishlist/remove',
|
||||
|
||||
@@ -138,6 +138,15 @@ export interface Account {
|
||||
* `runx admin grant-influencer`. Absent/false means no role.
|
||||
*/
|
||||
isInfluencer?: boolean
|
||||
/**
|
||||
* The ONE influencer this account supports in the partner program — the `accountId` of
|
||||
* an account flagged `isInfluencer`, chosen by the player via econ's
|
||||
* `POST /api/influencerpartnerprogram/support` and read back by `…/myinfluencer` (their
|
||||
* own) and `…/influencer?accountId=` (anyone's). Absent means nobody, which those routes
|
||||
* serve as 0. Re-posting replaces the pick; nothing here records the history. Not in any
|
||||
* account DTO: the two econ reads are the only surface.
|
||||
*/
|
||||
supportedInfluencerId?: number
|
||||
/**
|
||||
* Whether this account is a SANDBOX account — served out of the sandbox Tachyon pool
|
||||
* rather than the live one (`match` `GET /player/connection-info`). Operator-set only;
|
||||
|
||||
Reference in New Issue
Block a user