Files
recflare/apps/econ/src/test/integration/api.test.ts
T
2026-08-25 15:46:02 -04:00

2713 lines
107 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import '../../econ.app'
import {
getOwnedInventionIds,
getProgression,
INVENTORY_INVENTION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
RECEIVED_GIFT_SCHEMA_DDL,
} from '@repo/domain'
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
// is built here too (see the same cross-worker import in econ.app.ts).
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
// The notification-type ids the hub carries, from the worker that owns them — asserting
// against the enum rather than a copied number is what keeps these frames honest.
import { NotificationType } from '../../../../notify/src/notification-types'
// The live weekly rotation, so the challenge tests exercise whatever it currently holds
// instead of hard-coded ids from a rotation that has since been replaced.
import weeklyChallenge from '../../../static/weekly-challenge.json'
import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
CurrencyType,
DEFAULT_STARTING_TOKENS,
getBalance,
spendCurrency,
} from '../../balance-db'
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
/** The first challenge of the live rotation — the progress tests report against it. */
const CURRENT_CHALLENGE = weeklyChallenge.Challenges[0]
// Build the accounts table and seed the test player (the default token's sub, 42)
// so avatar reads/writes have a row to attach to.
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CHALLENGE_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CHALLENGE_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REWARD_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run()
for (const invention of SEEDED_INVENTIONS) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(invention))
.run()
}
})
/**
* Inventions the buyInvention tests buy (or fail to buy). Only the fields that path
* reads are meaningful — id, creator, published flag and price — but the record is
* shaped like a real stored `RRInvention` so the response envelope is realistic.
*/
function invention(
inventionId: number,
overrides: { CreatorPlayerId?: number; IsPublished?: boolean; Price?: number } = {}
) {
return {
InventionId: inventionId,
ReplicationId: `replication-${inventionId}`,
CreatorPlayerId: 999,
Name: `Invention ${inventionId}`,
Description: 'A test invention',
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: {
InventionId: inventionId,
ReplicationId: `version-${inventionId}`,
VersionNumber: 1,
BlobName: `invention-${inventionId}.inv`,
BlobHash: null,
InstantiationCost: 0,
LightsCost: 0,
ChipsCost: 0,
CloudVariablesCost: 0,
AICost: 0,
},
Accessibility: 0,
IsPublished: true,
IsFeatured: false,
ModifiedAt: '2026-01-01T00:00:00.000Z',
CreatedAt: '2026-01-01T00:00:00.000Z',
FirstPublishedAt: '2026-01-01T00:00:00.000Z',
CreationRoomId: 0,
NumPlayersHaveUsedInRoom: 0,
NumDownloads: 0,
CheerCount: 0,
CreatorPermission: 100,
GeneralPermission: 20,
IsAGInvention: false,
IsCertifiedInvention: false,
Price: 0,
AllowTrial: true,
HideFromPlayer: false,
ReferencedInventions: [],
...overrides,
}
}
const SEEDED_INVENTIONS = [
invention(8), // free, published, someone else's — the sellable one
invention(9, { Price: 250 }), // priced: buying it pays creator 999 250 tokens
invention(10, { IsPublished: false }), // a draft, not on sale even at 0
invention(11, { CreatorPlayerId: 60 }), // account 60's own invention
]
/**
* A real outfit as the client posts it to /api/avatar/v3/saved/set — kept verbatim
* (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the
* round-trip is tested against the actual payload shape, not a tidied-up version.
*/
const SAVED_OUTFIT = {
Slot: 4,
PreviewImageName: 'outfit/2026-07-14/38e84678-1ccf-4cfd-bf3f-5b21eec88b0f.jpg',
OutfitSelections:
'5cd08cfb-c729-4c30-96d9-6a99bb934d91,,1;77d3c585-4928-4471-a425-89036efe7299,,0;40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,,1',
OutfitSelectionsV2:
'{"selections":[{"PrefabGuid":"5cd08cfb-c729-4c30-96d9-6a99bb934d91","CombinationGuid":"","BodyPart":1,"UgcOutfitData":{"BaseAvatarItemColor":{"r":0.0,"g":0.0,"b":0.0,"a":0.0},"CustomAvatarItemId":""}}]}',
FaceFeatures:
'{"ver":6,"eyeId":"pY0dY6IxOEaNv8uNL8qUgQ","eyeScl":-0.007145103067159653,"useHelmetHair":1,"hideEars":false}',
SkinColor: 'Xac-W_R330KfOz-pQla9qg',
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
CustomAvatarItems: [],
}
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* A bearer token for `sub`. `roles` becomes the `role` claim the auth worker stamps from an
* account's flags — pass `['gameClient', 'developer']` for an elevated account; the default
* is no claim at all, which reads as no roles.
*/
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const claims =
roles === undefined ? { sub, exp: now + 3600 } : { sub, exp: now + 3600, role: roles }
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify(claims)
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
describe('econ endpoints', () => {
test('GET /api/avatar/v1/defaultunlocked returns the default avatar items', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultunlocked`)
expect(res.status).toBe(200)
const body = (await res.json()) as unknown[]
expect(Array.isArray(body)).toBe(true)
expect(body.length).toBeGreaterThan(0)
expect(body[0]).toHaveProperty('AvatarItemDesc')
})
test('GET /api/avatar/v1/defaultbaseavataritems returns the base items (no auth)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultbaseavataritems`)
expect(res.status).toBe(200)
const body = (await res.json()) as Array<Record<string, unknown>>
expect(body.map((i) => i.AvatarItemId)).toEqual([2184, 2918])
// The client keys these off IsBaseAvatarItem, and the trailing comma in the desc
// is part of the item descriptor — both are served verbatim.
expect(body.every((i) => i.IsBaseAvatarItem === true)).toBe(true)
expect(body[0]?.AvatarItemDesc).toBe('c5d70cb4-71dd-4fe4-b719-34fe2073c611,')
})
test('GET /api/avatar/v4/items 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`)
expect(res.status).toBe(401)
})
test('GET /api/avatar/v4/items serves the catalog in the camelCase v4 shape', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Array<Record<string, unknown>>
expect(body.length).toBeGreaterThan(0)
// Every key of the DTO is present on every item, and nothing PascalCase leaks
// through from the stored/bundled records.
for (const item of body) {
expect(Object.keys(item).sort()).toEqual([
'avatarItemDesc',
'avatarItemId',
'avatarItemType',
'friendlyName',
'isBaseAvatarItem',
'rarity',
'tagList',
'tooltip',
])
}
expect(typeof body[0]?.avatarItemDesc).toBe('string')
expect(typeof body[0]?.friendlyName).toBe('string')
// The catalog carries no ids, tags or base flag — those default rather than
// being invented.
expect(body[0]?.avatarItemId).toBe(0)
expect(body[0]?.tagList).toBe('')
expect(body[0]?.isBaseAvatarItem).toBe(false)
})
test('GET /api/avatar/v2 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`)
expect(res.status).toBe(401)
})
test('GET /api/avatar/v2 returns a populated default avatar when none is saved', async () => {
// Account 7 has no saved avatar → falls back to the default outfit.
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`, {
headers: await bearer('7'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { OutfitSelections: string; FaceFeatures: string }
// Must be non-empty — the client's outfit parser NREs on an empty string.
expect(body.OutfitSelections.length).toBeGreaterThan(0)
expect(body.OutfitSelections).toContain(';')
expect(body.FaceFeatures).toContain('eyeId')
})
test('POST /api/avatar/v2/set 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/set`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ OutfitSelections: 'a,,0' }),
})
expect(res.status).toBe(401)
})
test('POST /api/avatar/v2/set saves the avatar, and GET reads it back', async () => {
const headers = { ...(await bearer()), 'Content-Type': 'application/json' }
const avatar = {
OutfitSelections:
'1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,0;d0a9262f-5504-46a7-bb10-7507503db58e,,1',
OutfitSelectionsV2: '{"selections":[]}',
FaceFeatures: '{"eyeId":"AjGMoJhEcEehacRZjUMuDg"}',
SkinColor: '3529b670-a66d-448e-9573-1905eae5b9bf',
HairColor: '0e_jaaObREWTf1AorAZ95g',
CustomAvatarItems: [],
}
// Save echoes the payload back.
const setRes = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/set`, {
method: 'POST',
headers,
body: JSON.stringify(avatar),
})
expect(setRes.status).toBe(200)
expect(await setRes.json()).toEqual(avatar)
// And it persists — GET now returns the saved avatar, not the default.
const getRes = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`, {
headers: await bearer(),
})
expect(await getRes.json()).toEqual(avatar)
})
test('GET /api/avatar/v2/:id 400s on a non-numeric id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/notanumber`)
expect(res.status).toBe(400)
})
test('GET /api/avatar/v2/:id returns the default projection when none is saved (no auth)', async () => {
// Account 8 has no saved avatar → falls back to the default outfit. No token needed.
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/8`)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
// Projected to exactly the render subset — no OutfitSelectionsV2/CustomAvatarItems.
expect(Object.keys(body).sort()).toEqual([
'FaceFeatures',
'HairColor',
'OutfitSelections',
'SkinColor',
])
expect((body.OutfitSelections as string).length).toBeGreaterThan(0)
})
test('GET /api/avatar/v2/:id returns another players saved avatar, projected', async () => {
// Seed account 314 with a full avatar blob (superset of the projection).
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 314, username: 'Pi', displayName: 'Pi' }))
.run()
await env.DB.prepare('UPDATE account SET avatar = ?2 WHERE account_id = ?1')
.bind(
314,
JSON.stringify({
OutfitSelections: 'guid,,0;guid2,,1',
OutfitSelectionsV2: '{"selections":[]}',
FaceFeatures: '{"eyeId":"abc"}',
SkinColor: 'skin-guid',
HairColor: 'hair-guid',
CustomAvatarItems: [],
})
)
.run()
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/314`)
expect(res.status).toBe(200)
// Only the four projected fields, carrying the saved values.
expect(await res.json()).toEqual({
OutfitSelections: 'guid,,0;guid2,,1',
FaceFeatures: '{"eyeId":"abc"}',
SkinColor: 'skin-guid',
HairColor: 'hair-guid',
})
})
test('GET /api/avatar/v2/gifts is not shadowed by the :id route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /api/avatar/v2/set 404s when the caller has no account row', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/set`, {
method: 'POST',
headers: { ...(await bearer('99999')), 'Content-Type': 'application/json' },
body: JSON.stringify({ OutfitSelections: 'a,,0' }),
})
expect(res.status).toBe(404)
})
test('GET /econ/customAvatarItems/v1/owned 401s without a token, returns an empty paginated stub', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
test('GET /api/objectives/v1/myprogress returns the default progress (no auth)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/myprogress`)
expect(res.status).toBe(200)
const body = (await res.json()) as { Objectives: unknown[]; ObjectiveGroups: unknown[] }
expect(Array.isArray(body.Objectives)).toBe(true)
expect(Array.isArray(body.ObjectiveGroups)).toBe(true)
})
test('objectives/v1/cleargroup returns [] for GET and POST (no auth)', async () => {
for (const method of ['GET', 'POST'] as const) {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/cleargroup`, { method })
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
})
test('POST /api/objectives/v1/updateobjective echoes the group, never completed', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
Index: 2,
Group: 3,
Progress: 1,
VisualProgress: 0,
IsCompleted: true,
HasClaimedReward: false,
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { group: number; isCompleted: boolean; clearedAt: string }
expect(body.group).toBe(3)
expect(body.isCompleted).toBe(false)
expect(Number.isNaN(Date.parse(body.clearedAt))).toBe(false)
})
test('POST /api/objectives/v1/updateobjective tolerates a non-JSON body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
body: 'not json',
})
expect(res.status).toBe(200)
const body = (await res.json()) as { group: number; isCompleted: boolean }
expect(body.group).toBe(0)
expect(body.isCompleted).toBe(false)
})
test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => {
const expected = [
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 },
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 },
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 },
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 },
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 },
]
// Both version paths are live and serve the same list.
for (const path of ['/api/checklist/v1/current', '/api/checklist/v2/current']) {
const anon = await exports.default.fetch(`${ORIGIN}${path}`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}${path}`, { headers: await bearer() })
expect(res.status).toBe(200)
expect(await res.json()).toEqual(expected)
}
})
test('POST /api/checklist/v1|v2/complete 401s without a token, grants nothing with one', async () => {
for (const path of ['/api/checklist/v1/complete', '/api/checklist/v2/complete']) {
const anon = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ItemIndex: 1 }),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer('33')), 'Content-Type': 'application/json' },
body: JSON.stringify({ ItemIndex: 1 }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
BalanceUpdates: [{ UpdateResponse: 303, Data: [] }],
Balance: 0,
CurrencyType: 2,
BalanceType: -2,
})
}
// Stubbed, so completing rows does not move the balance — re-posting cannot farm
// tokens, and the checklist still lists every row.
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('33'),
})
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }])
})
test('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/me`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/me`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/itemWishlists/v1/wishlist/:accountId 401s without a token, returns []', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/207`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/207`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// `me` is still its own route, not read as an account id.
const mine = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/me`, {
headers: await bearer(),
})
expect(mine.status).toBe(200)
})
test('GET /api/avatar/v3/saved 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`)
expect(anon.status).toBe(401)
// Account 21 has saved nothing.
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, {
headers: await bearer('21'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /api/avatar/v3/saved/set saves an outfit, read back by /saved', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved/set`, {
method: 'POST',
body: JSON.stringify(SAVED_OUTFIT),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved/set`, {
method: 'POST',
headers: { ...(await bearer('22')), 'Content-Type': 'application/json' },
body: JSON.stringify(SAVED_OUTFIT),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual(SAVED_OUTFIT)
// Round-trips verbatim — including the JSON-in-a-string fields the client parses
// back itself (OutfitSelectionsV2, FaceFeatures).
const saved = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, {
headers: await bearer('22'),
})
expect(await saved.json()).toEqual([SAVED_OUTFIT])
})
test('POST /api/avatar/v3/saved/set overwrites the same slot, and keeps others', async () => {
const headers = await bearer('23')
const post = (outfit: unknown) =>
exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved/set`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(outfit),
})
await post({ ...SAVED_OUTFIT, Slot: 4, SkinColor: 'first' })
await post({ ...SAVED_OUTFIT, Slot: 7, SkinColor: 'other-slot' })
// Re-saving slot 4 replaces it rather than adding a second row for it.
await post({ ...SAVED_OUTFIT, Slot: 4, SkinColor: 'second' })
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, { headers })
const outfits = (await res.json()) as Array<{ Slot: number; SkinColor: string }>
expect(outfits.map((o) => [o.Slot, o.SkinColor])).toEqual([
[4, 'second'],
[7, 'other-slot'],
])
})
test('POST /api/avatar/v3/saved/set 400s without an integer Slot', async () => {
const { Slot: _Slot, ...noSlot } = SAVED_OUTFIT
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved/set`, {
method: 'POST',
headers: { ...(await bearer('24')), 'Content-Type': 'application/json' },
body: JSON.stringify(noSlot),
})
expect(res.status).toBe(400)
})
test('POST /api/avatar/v4/saved/set stores like v3 but acks with { Success, Slot }', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
method: 'POST',
body: JSON.stringify(SAVED_OUTFIT),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
method: 'POST',
headers: { ...(await bearer('25')), 'Content-Type': 'application/json' },
body: JSON.stringify(SAVED_OUTFIT),
})
expect(res.status).toBe(200)
// v4 answers a lean ack, not the echoed outfit.
expect(await res.json()).toEqual({ Success: true, Slot: SAVED_OUTFIT.Slot })
// Shares the v3 outfit table, so the v3 read serves the outfit back verbatim.
const saved = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, {
headers: await bearer('25'),
})
expect(await saved.json()).toEqual([SAVED_OUTFIT])
})
test('POST /api/avatar/v4/saved/set 400s without an integer Slot', async () => {
const { Slot: _Slot, ...noSlot } = SAVED_OUTFIT
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
method: 'POST',
headers: { ...(await bearer('26')), 'Content-Type': 'application/json' },
body: JSON.stringify(noSlot),
})
expect(res.status).toBe(400)
})
test('GET /api/avatar/v2/gifts 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/equipment/v2/getUnlocked 401s without a token, returns [] when none owned', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`)
expect(anon.status).toBe(401)
// Account 30 has bought no equipment → empty list.
const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
headers: await bearer('30'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/roomconsumables/v1/roomConsumable/room/:id returns []', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/1`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/roomcurrencies/v1/currencies returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/currencies?roomId=1`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/roomkeys/v1/room returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/room?roomId=1`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/1/me`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/roomcurrencies/v1/getAllBalances returns []', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/roomcurrencies/v1/getAllBalances?roomId=1`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
// The room-economy stubs. One table-driven test: they're the same empty-list answer,
// and what's worth pinning is that every path the client asks for on room entry is
// registered — an unregistered one 404s and stalls the room load.
test('the room-economy endpoints all return []', async () => {
for (const path of [
'/econ/roomInventory/room/92',
'/econ/roomInventory/room/92/player',
'/econ/roomInventoryItemTags/room/92',
'/econ/roomOffer/room/92',
'/econ/roomOffer/room/92/purchaseCounts',
'/econ/roomGiftDropShops/room/92',
'/api/ugcPurchasables/v1/items/room/92',
]) {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
expect(res.status, path).toBe(200)
expect(await res.json(), path).toEqual([])
}
})
test('GET /econ/roomEconConfig/:roomId echoes the room and disables sorting tabs', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ RoomId: 92, EnableSortingTabs: false })
const bad = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/nope`, {
headers: await bearer(),
})
expect(bad.status).toBe(400)
})
test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /api/consumables/v1/consume reduces the count and deletes the row at zero', async () => {
// Seed account 313 with two Supreme Pizza instances (counts 3 and 1).
await grantConsumable(env.DB, 313, 'Supreme Pizza', 3)
await grantConsumable(env.DB, 313, 'Supreme Pizza', 1)
type Group = { ConsumableItemDesc: string; Ids: number[]; Count: number }
const pizza = async (sub = '313'): Promise<Group | undefined> => {
const groups = (await (
await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer(sub),
})
).json()) as Group[]
return groups.find((g) => g.ConsumableItemDesc === 'Supreme Pizza')
}
const consume = async (Id: number, DeltaCount: number, sub = '313') =>
exports.default.fetch(`${ORIGIN}/api/consumables/v1/consume`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ Id, DeltaCount }),
})
const before = (await pizza())!
expect(before.Count).toBe(4)
const [firstId, secondId] = before.Ids // firstId: count 3, secondId: count 1
// No token → 401.
expect(
(
await exports.default.fetch(`${ORIGIN}/api/consumables/v1/consume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Id: firstId, DeltaCount: 1 }),
})
).status
).toBe(401)
// Consume 1 from the count-3 instance → it drops to 2, still present.
expect((await consume(firstId, 1)).status).toBe(200)
expect((await pizza())!.Count).toBe(3)
// Consume the whole count-1 instance → its row is deleted.
await consume(secondId, 1)
const afterSecond = (await pizza())!
expect(afterSecond.Ids).not.toContain(secondId)
expect(afterSecond.Count).toBe(2)
// Over-consume the remaining instance (delta > count) → row deleted, group gone.
await consume(firstId, 5)
expect(await pizza()).toBeUndefined()
// Consuming a row you don't own is a no-op (scoped to the owner).
await grantConsumable(env.DB, 314, 'Soda', 2)
const sodaId = (
(await (
await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('314'),
})
).json()) as Array<{ Ids: number[] }>
)[0].Ids[0]
await consume(sodaId, 2, '313') // account 313 tries to consume 314's row
const soda = (
(await (
await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('314'),
})
).json()) as Array<{ Count: number }>
)[0]
expect(soda.Count).toBe(2)
})
test('GET /api/storefronts/v4/balance/2 401s without a token, returns the token balance', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
// The starting grant, applied on this first read.
expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }])
})
test('GET /api/storefronts/v4/balance/2 reflects what the player has spent', async () => {
// Spend from account 7 (a fresh account: the read below grants it first).
expect(
await spendCurrency(env.DB, 7, CurrencyType.RecCenterTokens, 2500, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('7'),
})
expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 7500 }])
})
test('a spend the player cannot afford changes nothing', async () => {
const before = await getBalance(
env.DB,
8,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
expect(
await spendCurrency(
env.DB,
8,
CurrencyType.RecCenterTokens,
before + 1,
DEFAULT_STARTING_TOKENS
)
).toBe(false)
expect(await getBalance(env.DB, 8, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)).toBe(
before
)
})
test('the starting grant comes from the STARTING_TOKENS var', async () => {
// The grant an operator actually runs is the var; DEFAULT_STARTING_TOKENS is only the
// fallback. `env` is shared by every test in this file, so restore it in `finally`.
const original = env.STARTING_TOKENS
try {
env.STARTING_TOKENS = 250
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('11'),
})
expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 250 }])
} finally {
env.STARTING_TOKENS = original
}
})
test('the starting grant is not re-granted after spending down to zero', async () => {
// The grant is INSERT OR IGNORE against the row, not a top-up: a player who spends
// everything stays at 0 rather than being refilled by their next balance read.
expect(
await spendCurrency(env.DB, 9, CurrencyType.RecCenterTokens, 10_000, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('9'),
})
expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 0 }])
})
test('GET /api/storefronts/v4/balance for a room-scoped currency returns 0, not a balance', async () => {
// RoomCurrency (300) is scoped to a room and served elsewhere; this table must not
// hand out an account-wide balance for it.
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/300`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([{ CurrencyType: 300, Platform: -2, Balance: 0 }])
})
test('GET /api/storefronts/v3/giftdropstore/3 returns the storefront catalog', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/3`)
expect(res.status).toBe(200)
expect(await res.json()).toBeTruthy()
})
// Item 73 in sf3.json — "Bowtie (White)", 450 RecCenterTokens (CurrencyType 2).
test('POST /api/storefronts/v2/buyItem 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 450,
}),
})
expect(res.status).toBe(401)
})
test('POST /api/storefronts/v2/buyItem debits, grants the item, and hands back a gift box', async () => {
// Account 20: fresh, so its first balance touch grants the 10000 default.
await drainFrames()
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 450,
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Balance: number
CurrencyType: number
BalanceType: number
BalanceUpdates: Array<{
Data: Array<{ Id: number; AvatarItemDesc: string }>
}>
}
// `Balance` is the change applied (the negated price), not the resulting total.
expect(body.Balance).toBe(-450)
expect(body.CurrencyType).toBe(2)
expect(body.BalanceType).toBe(-2)
const gift = body.BalanceUpdates[0].Data[0]
expect(gift.AvatarItemDesc).not.toBe('')
expect(gift.Id).toBeGreaterThan(0)
// A purchase pushes StorefrontBalancePurchase, which SETS one (CurrencyType, Platform)
// bucket to an absolute value: `Balance` is the resulting total (10000 - 450) and `Delta`
// is display-only. The bucket key is `Platform`, and it MUST be the -2 the balance
// endpoint reports below — the client sums its buckets, so a frame naming any other
// platform (or spelling the key `BalanceType`, which the client's decoder drops) invents
// a second balance beside the real one. That is what showed a live player 34,100 tokens
// after spending 900 of 17,500, then 33,200 once the body's -900 landed.
expect(await drainFrames()).toEqual([
{
accountId: 20,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
// 1400 = CommercePurchase; -2 = NonPurchasedNotUsableInP2P, the only bucket we use.
BalanceAddType: 1400,
Delta: -450,
Balance: 9550,
Platform: -2,
CurrencyType: 2,
},
},
])
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450).
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('20'),
})
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 9550 }])
// The item is now owned — it leads the v4/items list (owned items prepend the catalog).
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('20'),
})
const list = (await items.json()) as Array<{ avatarItemDesc: string; friendlyName: string }>
expect(list[0].friendlyName).toBe('Bowtie (White)')
expect(list[0].avatarItemDesc).toBe(gift.AvatarItemDesc)
// And a pending gift box is waiting to be opened.
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('20'),
})
const pending = (await gifts.json()) as Array<{ Id: number; AvatarItemDesc: string }>
expect(pending).toHaveLength(1)
expect(pending[0].Id).toBe(gift.Id)
expect(pending[0].AvatarItemDesc).toBe(gift.AvatarItemDesc)
})
test('POST /api/storefronts/v2/buyItem grants a consumable and stacks on re-buy', async () => {
// Item 2266 (Supreme Pizza) in storefront 300 is a consumable — its gift-drop
// carries a ConsumableItemDesc, not an AvatarItemDesc.
const consumableDesc = 'wUCIKdJSvEmiQHYMyx4X4w'
const buy = async () =>
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('25')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 300,
PurchasableItemId: 2266,
CurrencyType: 2,
RequestedPrice: 95,
}),
})
const res = await buy()
expect(res.status).toBe(200)
const body = (await res.json()) as {
Balance: number
BalanceUpdates: Array<{
Data: Array<{
ConsumableItemDesc: string
AvatarItemDesc: string
AvatarItemType: number
FromPlayerId: number
}>
}>
}
// `Balance` is the change applied (the negated price), not the resulting total.
expect(body.Balance).toBe(-95)
const drop = body.BalanceUpdates[0].Data[0]
expect(drop.ConsumableItemDesc).toBe(consumableDesc)
expect(drop.AvatarItemDesc).toBe('')
// A consumable's AvatarItemType is null in the catalog; the response coalesces it to 0.
expect(drop.AvatarItemType).toBe(0)
// A self-buy is attributed to the "Coach" system account (id 1).
expect(drop.FromPlayerId).toBe(1)
// It's owned as an unlocked consumable — one instance, count 1.
const unlocked = async () => {
const r = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('25'),
})
expect(r.status).toBe(200)
return (await r.json()) as Array<{
Ids: number[]
CreatedAts: string[]
ConsumableItemDesc: string
Count: number
InitialCount: number
IsActive: boolean
IsTransferable: boolean
}>
}
const first = await unlocked()
expect(first).toHaveLength(1)
expect(first[0].ConsumableItemDesc).toBe(consumableDesc)
expect(first[0].Count).toBe(1)
expect(first[0].InitialCount).toBe(1)
expect(first[0].Ids).toHaveLength(1)
expect(first[0].CreatedAts).toHaveLength(1)
expect(first[0].IsActive).toBe(false)
expect(first[0].IsTransferable).toBe(false)
// A consumable is not an avatar item — it does not show up in v4/items.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('25'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Supreme Pizza')).toBe(true)
// Buying it again stacks: a second instance, count summed to 2.
expect((await buy()).status).toBe(200)
const second = await unlocked()
expect(second).toHaveLength(1)
expect(second[0].Count).toBe(2)
expect(second[0].InitialCount).toBe(2)
expect(second[0].Ids).toHaveLength(2)
expect(second[0].CreatedAts).toHaveLength(2)
})
test('POST /api/storefronts/v2/buyItem grants equipment, read back by getUnlocked, no re-buy dupe', async () => {
// Item 1950 (Disc Skin (Coop)) in storefront 3 is a pure equipment drop — its
// gift-drop carries an EquipmentModificationGuid but no avatar/consumable desc.
const guid = '19ef59c7-f74b-4c63-935a-1d4b1abd8518'
const buy = async () =>
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 1950,
CurrencyType: 2,
RequestedPrice: 3500,
}),
})
const res = await buy()
expect(res.status).toBe(200)
const body = (await res.json()) as {
Balance: number
BalanceUpdates: Array<{
Data: Array<{ Id: number; EquipmentModificationGuid: string; EquipmentPrefabName: string }>
}>
}
expect(body.Balance).toBe(-3500)
const gift = body.BalanceUpdates[0].Data[0]
expect(gift.EquipmentModificationGuid).toBe(guid)
expect(gift.EquipmentPrefabName).toBe('[DiscGolfDisc]')
const unlocked = async () => {
const r = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
headers: await bearer('31'),
})
expect(r.status).toBe(200)
return (await r.json()) as Array<{
ModificationGuid: string
PrefabName: string
FriendlyName: string
PlatformMask: number
Favorited: boolean
}>
}
const first = await unlocked()
expect(first).toHaveLength(1)
// The unlocked DTO is unprefixed, unlike the gift-drop the grant came from.
expect(first[0].ModificationGuid).toBe(guid)
expect(first[0].PrefabName).toBe('[DiscGolfDisc]')
expect(first[0].FriendlyName).toBe('Disc Skin (Coop)')
expect(first[0].PlatformMask).toBe(-1)
// Equipment is not an avatar item — it does not show up in v4/items.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('31'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Disc Skin (Coop)')).toBe(true)
expect(first[0].Favorited).toBe(false)
// Owning equipment is boolean: re-buying upserts, it does not add a second row.
expect((await buy()).status).toBe(200)
expect(await unlocked()).toHaveLength(1)
// Favouriting sticks.
const update = async (favorited: boolean) =>
exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
body: JSON.stringify([
{ PrefabName: '[DiscGolfDisc]', ModificationGuid: guid, Favorited: favorited },
// A guid the caller doesn't own is silently skipped, not inserted.
{ PrefabName: '[Basketball]', ModificationGuid: 'not-owned', Favorited: true },
]),
})
expect((await update(true)).status).toBe(200)
let after = await unlocked()
expect(after).toHaveLength(1)
expect(after[0].Favorited).toBe(true)
// …and un-favouriting flips it back.
expect((await update(false)).status).toBe(200)
after = await unlocked()
expect(after[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)
const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
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 () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('21')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 1,
}),
})
expect(res.status).toBe(409)
// Nothing was charged.
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('21'),
})
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }])
})
test('POST /api/storefronts/v2/buyItem 404s for an unknown item', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('22')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 9999999,
CurrencyType: 2,
RequestedPrice: 450,
}),
})
expect(res.status).toBe(404)
})
test('POST /api/storefronts/v2/buyItem 400s when the player cannot afford it', async () => {
// Drain account 23 to 0 first, then try to buy.
expect(
await spendCurrency(env.DB, 23, CurrencyType.RecCenterTokens, 10_000, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('23')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 450,
}),
})
expect(res.status).toBe(400)
// Still owns nothing (only the default catalog in v4/items).
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('23'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Bowtie (White)')).toBe(true)
})
// ---- POST /api/items/bulkpurchase ------------------------------------------------
// The shopping bag: many lines, one storefront, one currency, one debit. Its response is
// NOT buyItem's — it is the `{ Success, Error, error_id, Value }` envelope, `Value.Balance`
// is the RESULTING total rather than the change, and each `BalanceUpdates` entry carries
// its own `UpdateResponse` (0 OK, 2 NotEnoughCredit, 4 NoItemAvailable, 5
// CouponNotApplicable, 6 RequestedPriceDoesNotMatch, 7 RequestedAmountNotAllowed).
/** The shape every bulk-purchase response answers with. */
type BulkBody = {
Success: boolean
Error: string | null
error_id: string | null
Value: {
Balance: number
CurrencyType: number
Platform: number
BalanceUpdates: Array<{
UpdateResponse: number
Data: {
GiftPackage: Record<string, unknown> | null
PurchasableItemId: number | null
CustomAvatarItem: null
}
}>
} | null
}
/** A bag line, in the shape the client posts one. */
const line = (numberId: number, requestedPrice: number, extra: Record<string, unknown> = {}) => ({
ItemPurchaseMethodId: { Type: 0, NumberId: numberId, Guid: null },
RequestedPrice: requestedPrice,
Gift: null,
CouponConsumablePlayerMappingId: null,
DuplicateItemCount: 1,
...extra,
})
const bulkPurchase = async (sub: string, body: Record<string, unknown>) =>
exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
CurrencyType: 2,
BypassGiftPackages: false,
AllowPartialSuccess: true,
ShoppingBagId: null,
...body,
}),
})
/** The `UpdateResponse` of every entry, in request order. */
const codes = (body: BulkBody) => body.Value!.BalanceUpdates.map((u) => u.UpdateResponse)
test('POST /api/items/bulkpurchase 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
PurchaseItemRequests: [line(10, 200)],
StorefrontType: 3,
CurrencyType: 2,
}),
})
expect(res.status).toBe(401)
})
test('POST /api/items/bulkpurchase debits the bag once and grants every line', async () => {
// Account 90: fresh, so its first balance touch grants the 10000 default. Three donuts
// (a consumable, 100 each — consumables are the only thing that stacks) and one dress
// (an avatar item, 200) — 500 in total.
await drainFrames()
const res = await bulkPurchase('90', {
PurchaseItemRequests: [line(2182, 100, { DuplicateItemCount: 3 }), line(10, 200)],
ShoppingBagId: 'bag-1',
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Error).toBe(null)
expect(body.error_id).toBe(null)
const value = body.Value!
// `Balance` here is the RESULTING total (10000 - 500), unlike buyItem's change. The
// bucket is -2, the one `GET /balance` reports — the reference server's 4
// (RecNetPurchased) would read as a second balance the client adds to the real one.
expect(value.Balance).toBe(9500)
expect(value.CurrencyType).toBe(2)
expect(value.Platform).toBe(-2)
// ONE entry per REQUESTED item — three donuts are one line, so one entry — in order.
expect(value.BalanceUpdates).toHaveLength(2)
expect(codes(body)).toEqual([0, 0])
expect(value.BalanceUpdates.map((u) => u.Data.PurchasableItemId)).toEqual([2182, 10])
expect(value.BalanceUpdates.every((u) => u.Data.CustomAvatarItem === null)).toBe(true)
// The box each line produced, as `GiftPackage` carries it: 20 keys, the receiver in
// `PlayerId`, a self-buy attributed to the "Coach" account (1), and the platform MASK in
// `Platform` — the balance bucket is the `BalanceType` beside it.
const box = value.BalanceUpdates[0].Data.GiftPackage!
expect(Object.keys(box)).toEqual([
'Id',
'PlayerId',
'FromPlayerId',
'ConsumableItemDesc',
'AvatarItemType',
'AvatarItemDesc',
'CustomAvatarItemId',
'EquipmentPrefabName',
'EquipmentModificationGuid',
'CurrencyType',
'Currency',
'Xp',
'GiftContext',
'GiftRarity',
'Message',
'Signature',
'IsSignatureValid',
'Platform',
'PlatformsToSpawnOn',
'BalanceType',
])
expect(box.Id).toBeGreaterThan(0)
expect(box.PlayerId).toBe(90)
expect(box.FromPlayerId).toBe(1)
expect(box.ConsumableItemDesc).not.toBe('')
expect(box.Platform).toBe(-1)
expect(box.BalanceType).toBe(-2)
expect(value.BalanceUpdates[1].Data.GiftPackage!.AvatarItemDesc).not.toBe('')
// ONE frame for the whole bag, setting the account-wide bucket to the resulting total —
// the same 9500 the body reports, so the two agree instead of compounding.
expect(await drainFrames()).toEqual([
{
accountId: 90,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
BalanceAddType: 1400,
Delta: -500,
Balance: 9500,
Platform: -2,
CurrencyType: 2,
},
},
])
expect(
await getBalance(env.DB, 90, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(9500)
// Everything landed: the dress is owned, all three donuts stacked into the one box's
// grant, and each LINE left one gift box.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('90'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list[0].friendlyName).toBe('Babydoll Dress (Blue)')
const unlocked = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('90'),
})
const consumables = (await unlocked.json()) as Array<{ Count: number }>
expect(consumables[0].Count).toBe(3)
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('90'),
})
const pending = (await gifts.json()) as Array<{ Id: number }>
expect(pending.map((g) => g.Id)).toEqual(
value.BalanceUpdates.map((u) => u.Data.GiftPackage!.Id)
)
})
test('POST /api/items/bulkpurchase buys the good lines when partial success is allowed', async () => {
// The second line's price no longer matches the catalog (200, not 1). The bag still
// succeeds — that entry just comes back non-OK, which is what AllowPartialSuccess means.
const res = await bulkPurchase('91', {
PurchaseItemRequests: [line(10, 200), line(80, 1)],
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Error).toBe(null)
expect(body.Value!.Balance).toBe(9800)
// 6 = RequestedPriceDoesNotMatch. The failed line still names the item it asked for.
expect(codes(body)).toEqual([0, 6])
expect(body.Value!.BalanceUpdates[1].Data).toEqual({
GiftPackage: null,
PurchasableItemId: 80,
CustomAvatarItem: null,
})
expect(
await getBalance(env.DB, 91, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(9800)
})
test('POST /api/items/bulkpurchase charges nothing when a line fails and partial success is off', async () => {
await drainFrames()
const res = await bulkPurchase('92', {
AllowPartialSuccess: false,
PurchaseItemRequests: [line(10, 200), line(80, 1)],
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(false)
expect(body.Error).toBe('Price has changed')
expect(body.Value).toBe(null)
// Untouched: no debit, no item, and no frame for a purchase that did not happen.
expect(
await getBalance(env.DB, 92, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(10000)
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('92'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Babydoll Dress (Blue)')).toBe(true)
expect(await drainFrames()).toEqual([])
})
test('POST /api/items/bulkpurchase takes the lines that fit, in request order', async () => {
// Leave account 93 with 250 tokens: enough for the first 200-token line, not both.
await getBalance(env.DB, 93, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
expect(
await spendCurrency(env.DB, 93, CurrencyType.RecCenterTokens, 9750, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await bulkPurchase('93', {
PurchaseItemRequests: [line(10, 200), line(80, 200)],
})
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Value!.Balance).toBe(50)
// 2 = NotEnoughCredit for the line the balance no longer covered.
expect(codes(body)).toEqual([0, 2])
expect(body.Value!.BalanceUpdates[1].Data.GiftPackage).toBe(null)
expect(
await getBalance(env.DB, 93, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(50)
})
test('POST /api/items/bulkpurchase fails the whole bag it cannot afford when partial success is off', async () => {
await getBalance(env.DB, 94, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
expect(
await spendCurrency(env.DB, 94, CurrencyType.RecCenterTokens, 9750, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await bulkPurchase('94', {
AllowPartialSuccess: false,
PurchaseItemRequests: [line(10, 200), line(80, 200)],
})
const body = (await res.json()) as BulkBody
// Even the line that would have fitted is refused: all of it or none.
expect(body.Success).toBe(false)
expect(body.Error).toBe('Insufficient balance')
expect(body.Value).toBe(null)
expect(
await getBalance(env.DB, 94, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(250)
})
test('POST /api/items/bulkpurchase grants without gift boxes when BypassGiftPackages is set', async () => {
const res = await bulkPurchase('95', {
BypassGiftPackages: true,
PurchaseItemRequests: [line(10, 200)],
})
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Value!.Balance).toBe(9800)
// No box was created, so there is none to hand back — the capture's null GiftPackage.
expect(body.Value!.BalanceUpdates[0]).toEqual({
UpdateResponse: 0,
Data: { GiftPackage: null, PurchasableItemId: 10, CustomAvatarItem: null },
})
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('95'),
})
expect((await gifts.json()) as unknown[]).toEqual([])
// Ownership never depended on the box: the item is owned all the same.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('95'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list[0].friendlyName).toBe('Babydoll Dress (Blue)')
})
test('POST /api/items/bulkpurchase reports per line what it cannot sell', async () => {
await drainFrames()
const res = await bulkPurchase('96', {
PurchaseItemRequests: [
// A guid-keyed (UGC) item — nothing here sells one, and it has no NumberId to echo.
line(0, 200, {
ItemPurchaseMethodId: { Type: 1, NumberId: null, Guid: 'a3f1-not-a-catalog-item' },
}),
// Nothing issues coupons, so a line claiming one is refused rather than charged full
// price for a discount it thinks it applied.
line(10, 200, { CouponConsumablePlayerMappingId: 4242 }),
line(999999, 200),
line(10, 200, { DuplicateItemCount: 0 }),
// An avatar item is owned once — a second copy would grant nothing and charge for it.
line(80, 200, { DuplicateItemCount: 2 }),
// The catalog prices this item in RecCenterTokens only.
line(2182, 100),
// …and one that works, so the bag is a partial success rather than a refusal.
line(10, 200),
],
CurrencyType: 2,
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
// 4 NoItemAvailable, 5 CouponNotApplicable, 4 NoItemAvailable, 7/7
// RequestedAmountNotAllowed, 0 OK (the donuts do price in tokens), 0 OK.
expect(codes(body)).toEqual([4, 5, 4, 7, 7, 0, 0])
expect(body.Value!.BalanceUpdates[0].Data.PurchasableItemId).toBe(null)
// Only the two OK lines were charged (100 + 200).
expect(body.Value!.Balance).toBe(9700)
expect(await drainFrames()).toHaveLength(1)
})
test('POST /api/items/bulkpurchase refuses a bag where nothing sells', async () => {
const res = await bulkPurchase('97', {
CurrencyType: CurrencyType.LaserTagTickets,
PurchaseItemRequests: [line(10, 200)],
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
// Nothing was bought, so this is a refusal rather than a bag of non-OK entries.
expect(body.Success).toBe(false)
expect(body.Error).toBe('Currency type not available for this item')
expect(body.Value).toBe(null)
})
test('POST /api/items/bulkpurchase 400s on a request it cannot evaluate', async () => {
// Same envelope on a 400, so a client that only parses this shape still reads the error.
const empty = await bulkPurchase('98', { PurchaseItemRequests: [] })
expect(empty.status).toBe(400)
const emptyBody = (await empty.json()) as BulkBody
expect(emptyBody).toMatchObject({ Success: false, error_id: null, Value: null })
expect(emptyBody.Error).toBe('PurchaseItemRequests must be a non-empty array')
// A room-scoped currency is not an account balance we can debit.
const roomCurrency = await bulkPurchase('98', {
CurrencyType: CurrencyType.RoomCurrency,
PurchaseItemRequests: [line(10, 200)],
})
expect(roomCurrency.status).toBe(400)
expect(((await roomCurrency.json()) as BulkBody).Error).toBe('Currency type is not spendable')
// Over `Econ.BulkPurchaseCap` (200 copies) — the same cap the client reads from its
// game config. Consumables are what can be asked for in that quantity.
const over = await bulkPurchase('98', {
PurchaseItemRequests: [line(2182, 100, { DuplicateItemCount: 201 })],
})
expect(over.status).toBe(400)
expect(((await over.json()) as BulkBody).Error).toBe('A bulk purchase is capped at 200 items')
expect(
await getBalance(env.DB, 98, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(10000)
})
/**
* The StorefrontBalanceUpdate (and other) frames the worker has pushed since the last
* drain, read back off the stub hub in vitest.config.ts. Notification sends are
* best-effort — the worker logs and swallows a hub failure — so this is the only way a
* test sees what was actually pushed.
*/
const drainFrames = async (): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
> =>
(
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
drainFrames(): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
>
}
).drainFrames()
// buyInvention is a GET with query params — that is how the client sends it.
const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) =>
exports.default.fetch(
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=${inventionId}&requestedPrice=${requestedPrice}`,
{ headers: await bearer(sub) }
)
test('GET /api/storefronts/v2/buyInvention 401s without a token', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=8&requestedPrice=0`
)
expect(res.status).toBe(401)
})
test('GET /api/storefronts/v2/buyInvention records ownership of a free invention', async () => {
const res = await buyInvention('50', 8)
expect(res.status).toBe(200)
const body = (await res.json()) as {
BalanceUpdateResponse: {
Balance: number
BalanceType: number
CurrencyType: number
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
}
InventionResponse: {
Status: number
Invention: { InventionId: number; Name: string }
InventionVersion: { InventionId: number; VersionNumber: number }
}
}
// Nothing was debited, so `Balance` is the resulting total — the untouched starting
// grant — not a change, unlike buyItem's.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
expect(body.BalanceUpdateResponse.BalanceType).toBe(-2)
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
expect(body.InventionResponse.Status).toBe(0)
expect(body.InventionResponse.Invention.Name).toBe('Invention 8')
expect(body.InventionResponse.InventionVersion.VersionNumber).toBe(1)
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
// Owning an invention is boolean: buying it again is a conflict, not a second row.
expect((await buyInvention('50', 8)).status).toBe(409)
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
})
test('GET /api/storefronts/v2/buyInvention pays the creator the buyers tokens', async () => {
// Invention 9 costs 250 and was made by account 999. Buying it moves 250 tokens from
// the buyer to that creator — no house cut, so the two sides are equal and opposite.
await drainFrames()
const res = await buyInvention('51', 9, 250)
expect(res.status).toBe(200)
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
// `Balance` is the buyer's RESULTING total, so it already has the debit in it.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 51, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS - 250)
// The creator had never touched their balance: they keep their starting grant AND get
// paid, rather than the payout standing in for the grant.
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS + 250)
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
// Both sides get a frame carrying their RESULTING TOTAL, into the same -2 bucket the
// balance endpoint reports — a StorefrontBalance* push SETS that bucket, so sending the
// change (250 / -250) would set their whole balance to it. The creator sold, so theirs is
// a plain update; the buyer bought, so theirs is a purchase frame with a display-only
// `Delta`. Note the key is `Platform`: the client renames `BalanceType` away and drops it.
expect(await drainFrames()).toEqual([
{
accountId: 999,
notificationType: NotificationType.StorefrontBalanceUpdate,
payload: {
Balance: DEFAULT_STARTING_TOKENS + 250,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: -2,
},
},
{
accountId: 51,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
BalanceAddType: 1400,
Delta: -250,
Balance: DEFAULT_STARTING_TOKENS - 250,
Platform: -2,
CurrencyType: CurrencyType.RecCenterTokens,
},
},
])
})
test('GET /api/storefronts/v2/buyInvention rejects a stale price and an unaffordable one', async () => {
// Sending 0 for the 250-token invention 9 is a stale (or tampered) price.
expect((await buyInvention('53', 9, 0)).status).toBe(409)
// Account 54 can't afford it: nothing is debited, nobody is paid, nothing is owned.
await spendCurrency(
env.DB,
54,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS,
DEFAULT_STARTING_TOKENS
)
const creatorBefore = await getBalance(
env.DB,
999,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
expect((await buyInvention('54', 9, 250)).status).toBe(400)
expect(
await getBalance(env.DB, 54, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(0)
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(creatorBefore)
expect(await getOwnedInventionIds(env.DB, 53)).toEqual([])
expect(await getOwnedInventionIds(env.DB, 54)).toEqual([])
})
test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => {
// Unpublished — a draft is not on sale, free or not.
expect((await buyInvention('52', 10)).status).toBe(403)
// Account 60 created invention 11; a creator already owns it.
expect((await buyInvention('60', 11)).status).toBe(400)
expect((await buyInvention('52', 9999)).status).toBe(404)
// Missing/non-numeric inventionId.
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyInvention`, {
headers: await bearer('52'),
})
expect(res.status).toBe(400)
expect(await getOwnedInventionIds(env.DB, 52)).toEqual([])
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
})
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
// Buy an item for account 24, then consume the box the way the client does: on the
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
const buy = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('24')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 450,
}),
})
const bought = (await buy.json()) as {
BalanceUpdates: Array<{ Data: Array<{ Id: number }> }>
}
const giftId = bought.BalanceUpdates[0].Data[0].Id
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
method: 'POST',
headers: {
...(await bearer('24')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ Id: String(giftId), UnlockedLevel: '0' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ error: '', success: true, value: null })
// The box is gone; the item stays owned (it was granted at purchase, not on open).
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('24'),
})
expect(await gifts.json()).toEqual([])
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('24'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.some((i) => i.friendlyName === 'Bowtie (White)')).toBe(true)
// Opening it again is a harmless no-op — still 200.
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
method: 'POST',
headers: {
...(await bearer('24')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ Id: String(giftId) }),
})
expect(again.status).toBe(200)
})
test('POST /api/avatar/v2/gifts/consume opens a consumable box (fires ConsumableMappingAdded)', async () => {
// Buy a consumable (Supreme Pizza, item 2266 in storefront 300) for account 26 —
// its gift box carries a ConsumableItemDesc, so opening it notifies the client.
const buy = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('26')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 300,
PurchasableItemId: 2266,
CurrencyType: 2,
RequestedPrice: 95,
}),
})
expect(buy.status).toBe(200)
const giftId = (
(await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }
).BalanceUpdates[0].Data[0].Id
// Opening the box succeeds and fires the ConsumableMappingAdded push (which no-ops
// against the test hub stub — this asserts the notify path doesn't throw).
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, {
method: 'POST',
headers: { ...(await bearer('26')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ Id: String(giftId) }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ error: '', success: true, value: null })
// The box is gone; the consumable stays owned (granted at purchase).
expect(
await (
await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('26'),
})
).json()
).toEqual([])
const unlocked = (await (
await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('26'),
})
).json()) as Array<{ ConsumableItemDesc: string }>
expect(unlocked.length).toBeGreaterThan(0)
})
test('POST /api/avatar/v2/gifts/consume 403s when the box belongs to another player', async () => {
// Account 27 buys an item, producing a gift box owned by 27.
const buy = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('27')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 450,
}),
})
const giftId = (
(await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }
).BalanceUpdates[0].Data[0].Id
// Account 28 trying to open 27's box is forbidden — and 27 keeps it.
const forbidden = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, {
method: 'POST',
headers: { ...(await bearer('28')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ Id: String(giftId) }),
})
expect(forbidden.status).toBe(403)
const stillThere = (await (
await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { headers: await bearer('27') })
).json()) as Array<{ Id: number }>
expect(stillThere.some((g) => g.Id === giftId)).toBe(true)
// The owner (27) opens it fine, and re-opening the now-gone box is a harmless 200.
const ok = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, {
method: 'POST',
headers: { ...(await bearer('27')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ Id: String(giftId) }),
})
expect(ok.status).toBe(200)
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, {
method: 'POST',
headers: { ...(await bearer('27')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ Id: String(giftId) }),
})
expect(again.status).toBe(200)
})
test('GET /api/challenge/v2/getCurrent returns the weekly challenge', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
expect(res.status).toBe(200)
const body = (await res.json()) as { ChallengeMapId: number; Challenges: unknown[] }
expect(body).toHaveProperty('ChallengeMapId')
expect(Array.isArray(body.Challenges)).toBe(true)
})
test('GET /api/storefronts/v1/adcarouselitems returns the carousel items', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/adcarouselitems`)
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ AdCarouselItemId: number }>
expect(Array.isArray(body)).toBe(true)
expect(body[0]).toHaveProperty('AdCarouselItemId')
})
test('GET /api/gamerewards/v1/pending returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/pending`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /api/challenge/v2/updateProgress echoes the challenge and its stored completion', async () => {
// Post the live rotation's own challenge and rule tree — what the client actually
// sends — so editing static/weekly-challenge.json can't quietly stale this test.
const challenge = CURRENT_CHALLENGE
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers: { ...(await bearer('70')), 'Content-Type': 'application/json' },
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId),
Config: challenge.Config,
// .NET's bool.ToString() — the capitalized string, which `Boolean("False")`
// would read as complete.
Complete: 'False',
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
ChallengeMapId: weeklyChallenge.ChallengeMapId,
ChallengeId: challenge.ChallengeId,
Config: challenge.Config,
Complete: false,
})
})
test('POST /api/challenge/v2/updateProgress is 401 without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ChallengeMapId: '17', ChallengeId: '49', Complete: 'True' }),
})
expect(res.status).toBe(401)
})
test('a completed challenge persists and getCurrent stamps it for that player only', async () => {
const completedId = CURRENT_CHALLENGE.ChallengeId
const bearerHeaders = await bearer('71')
const posted = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers: { ...bearerHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeId: completedId,
Complete: 'True',
}),
})
expect(posted.status).toBe(200)
const mine = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
headers: bearerHeaders,
})
const body = (await mine.json()) as {
Challenges: Array<{ ChallengeId: number; Complete: boolean }>
}
// Only the reported one is stamped; the rest of the rotation is untouched.
expect(body.Challenges.filter((ch) => ch.Complete).map((ch) => ch.ChallengeId)).toEqual([
completedId,
])
// A different player, and an anonymous caller, still see the static catalog.
const other = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
headers: await bearer('72'),
})
const otherBody = (await other.json()) as { Challenges: Array<{ Complete: boolean }> }
expect(otherBody.Challenges.some((ch) => ch.Complete)).toBe(false)
const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
const anonBody = (await anon.json()) as { Challenges: Array<{ Complete: boolean }> }
expect(anonBody.Challenges.some((ch) => ch.Complete)).toBe(false)
})
test('completion latches within a rotation but resets on a new one', async () => {
const headers = { ...(await bearer('73')), 'Content-Type': 'application/json' }
// A challenge id of its own, so this says nothing about the live rotation.
const post = (ChallengeMapId: string, Complete: string) =>
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers,
body: JSON.stringify({ ChallengeMapId, ChallengeId: '9001', Complete }),
})
const completeOf = async (res: Response) =>
((await res.json()) as { Complete: boolean }).Complete
expect(await completeOf(await post('17', 'True'))).toBe(true)
// A later report that says "not complete" must not un-finish it.
expect(await completeOf(await post('17', 'False'))).toBe(true)
// …but the same challenge id in the NEXT rotation starts over.
expect(await completeOf(await post('18', 'False'))).toBe(false)
expect(await completeOf(await post('18', 'True'))).toBe(true)
})
test('the reported Config is stored and served back over the static rule tree', async () => {
const challenge = CURRENT_CHALLENGE
const bearerHeaders = await bearer('74')
const headers = { ...bearerHeaders, 'Content-Type': 'application/json' }
// The client posts the catalog's tree with its own running count written into it —
// `cc` on the counter — which is the progress that has to survive the session.
const inProgress = challenge.Config.replace(/}$/, ',"cc":1}')
expect(inProgress).not.toBe(challenge.Config)
const post = (body: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers,
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId),
...body,
}),
})
const reported = await post({ Config: inProgress, Complete: 'False' })
expect(await reported.json()).toEqual({
ChallengeMapId: weeklyChallenge.ChallengeMapId,
ChallengeId: challenge.ChallengeId,
Config: inProgress,
Complete: false,
})
const configOf = async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
headers: bearerHeaders,
})
const body = (await res.json()) as {
Challenges: Array<{ ChallengeId: number; Config: string }>
}
return body.Challenges.find((ch) => ch.ChallengeId === challenge.ChallengeId)?.Config
}
expect(await configOf()).toBe(inProgress)
// A report carrying no tree is not a reset — the stored progress stays, and is echoed.
const noConfig = await post({ Complete: 'False' })
expect(((await noConfig.json()) as { Config: string }).Config).toBe(inProgress)
expect(await configOf()).toBe(inProgress)
// Challenges this player never reported keep the authored tree, and so does everyone else.
const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
const anonBody = (await anon.json()) as { Challenges: Array<{ Config: string }> }
expect(anonBody.Challenges.map((ch) => ch.Config)).toEqual(
weeklyChallenge.Challenges.map((ch) => ch.Config)
)
})
/**
* How many of the rotation's challenges earn the gift — three, unless the rotation
* publishes fewer or declares itself all-or-nothing (`CHALLENGES_REQUIRED_FOR_GIFT`).
*/
const REQUIRED_FOR_GIFT = weeklyChallenge.CompletedRequired
? weeklyChallenge.Challenges.length
: Math.min(3, weeklyChallenge.Challenges.length)
/** Report the live rotation's challenges complete, for one player. */
async function finishTheRotation(sub: string) {
const headers = { ...(await bearer(sub)), 'Content-Type': 'application/json' }
const ids = weeklyChallenge.Challenges.map((challenge) => challenge.ChallengeId)
const report = (challengeId: number) =>
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers,
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeId: String(challengeId),
Complete: 'True',
}),
})
return { ids, report }
}
/** A player's unopened gift boxes, as the client reads them back. */
async function giftBoxes(sub: string) {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer(sub),
})
return (await res.json()) as Array<{
Id: number
Message: string
EquipmentModificationGuid: string
AvatarItemDesc: string
ConsumableItemDesc: string
GiftRarity: number
}>
}
test('completing enough of the rotation grants its gift, once', async () => {
// The live rotation, so this follows whatever static/weekly-challenge.json holds.
const { ids, report } = await finishTheRotation('74')
// The threshold can't ask for more than the week publishes: a five-challenge week asks
// for three, and a rotation of three or fewer asks for all of them.
expect(REQUIRED_FOR_GIFT).toBeLessThanOrEqual(ids.length)
for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) {
expect((await report(id)).status).toBe(200)
}
// One short of the threshold — the gift isn't due yet, even though challenges remain
// unfinished either way.
expect(await giftBoxes('74')).toEqual([])
await drainFrames()
expect((await report(ids[REQUIRED_FOR_GIFT - 1] ?? 0)).status).toBe(200)
const won = await giftBoxes('74')
expect(won).toHaveLength(1)
expect(won[0]?.Message).toBe('Weekly challenge complete!')
expect(won[0]?.EquipmentModificationGuid).toBe(weeklyChallenge.Gift.EquipmentModificationGuid)
// The client is told the moment the set is finished, rather than finding the box the
// next time it reads the gifts list. `Immediate` (31), from Coach (1).
const frames = await drainFrames()
expect(frames).toHaveLength(1)
expect(frames[0]?.accountId).toBe(74)
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
expect(frames[0]?.payload).toEqual({
Id: won[0]?.Id,
FromGiftDropId: 0,
FromPlayerId: 1,
ConsumableItemDesc: '',
AvatarItemDesc: weeklyChallenge.Gift.AvatarItemDesc,
AvatarItemType: weeklyChallenge.Gift.AvatarItemType,
EquipmentPrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
EquipmentModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
CurrencyType: 0,
Currency: 0,
Xp: 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: -2,
GiftContext: weeklyChallenge.Gift.GiftContext,
// The catalog's rarity for the item, not the block's `GiftRarity` of 0.
GiftRarity: 5,
Message: 'Weekly challenge complete!',
})
// The reward is the item, not the box: it lands in the inventory unopened.
const unlocked = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
headers: await bearer('74'),
})
const owned = (await unlocked.json()) as Array<{ ModificationGuid: string }>
expect(owned.map((e) => e.ModificationGuid)).toContain(
weeklyChallenge.Gift.EquipmentModificationGuid
)
// Finishing the REST of the set, and re-reporting what's already done (which the client
// keeps doing), must not mint a second reward.
for (const id of ids) expect((await report(id)).status).toBe(200)
expect(await giftBoxes('74')).toHaveLength(1)
})
test('a player who already owns the rotations gift rolls the fallback box instead', async () => {
// Own the reward up front — the case the rotation's `FallbackGiftName` exists for.
await grantEquipment(env.DB, 75, {
ModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
PrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
FriendlyName: 'Camera Skin (Comic)',
Tooltip: '',
Rarity: 5,
PlatformMask: -1,
Favorited: false,
})
const { ids, report } = await finishTheRotation('75')
for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) {
expect((await report(id)).status).toBe(200)
}
await drainFrames()
expect((await report(ids[REQUIRED_FOR_GIFT - 1] ?? 0)).status).toBe(200)
const won = await giftBoxes('75')
expect(won).toHaveLength(1)
// Something they don't have, at the tier `FallbackGiftName` names ("4-Star Box" → 30),
// rather than a second copy of the gift.
const rolled = won[0]
expect(rolled?.EquipmentModificationGuid).not.toBe(
weeklyChallenge.Gift.EquipmentModificationGuid
)
expect(rolled?.GiftRarity).toBe(30)
expect(
(rolled?.AvatarItemDesc ?? '') !== '' || (rolled?.EquipmentModificationGuid ?? '') !== ''
).toBe(true)
// The frame announces what was ROLLED, not the box that promised it — so the client
// pops the item they actually won.
const frames = await drainFrames()
expect(frames).toHaveLength(1)
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
expect(frames[0]?.payload).toMatchObject({
Id: rolled?.Id,
FromPlayerId: 1,
GiftRarity: 30,
AvatarItemDesc: rolled?.AvatarItemDesc,
EquipmentModificationGuid: rolled?.EquipmentModificationGuid,
Message: 'Weekly challenge complete!',
})
})
test('buying a query drop rolls a real item into the buyers inventory', async () => {
// sf2's "4-Star Unique Box" (539) — an `IsQuery` drop with no item fields of its own,
// which before the roll existed debited the buyer and granted nothing.
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('76')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 2,
PurchasableItemId: 539,
CurrencyType: CurrencyType.RecCenterTokens,
RequestedPrice: 800,
}),
})
expect(res.status).toBe(200)
// The RESPONSE describes what the roll landed on, not the box that was bought: the
// client draws the purchase from this entry, and the box's own fields are all empty.
const bought = (await res.json()) as {
BalanceUpdates: Array<{
Data: Array<{
AvatarItemDesc: string
EquipmentModificationGuid: string
GiftRarity: number
}>
}>
}
const entry = bought.BalanceUpdates[0]?.Data[0]
expect(entry?.GiftRarity).toBe(30)
expect(`${entry?.AvatarItemDesc ?? ''}${entry?.EquipmentModificationGuid ?? ''}`).not.toBe('')
const boxes = await giftBoxes('76')
expect(boxes).toHaveLength(1)
// The box shows what was rolled — a real 4-star item, not the empty box drop.
expect(boxes[0]?.GiftRarity).toBe(30)
expect(entry?.AvatarItemDesc).toBe(boxes[0]?.AvatarItemDesc)
const key = (box?: { AvatarItemDesc: string; EquipmentModificationGuid: string }) =>
`${box?.AvatarItemDesc ?? ''}|${box?.EquipmentModificationGuid ?? ''}`
expect(key(boxes[0])).not.toBe('|')
// …and it is already in their inventory, unopened box or not.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('76'),
})
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
if ((boxes[0]?.AvatarItemDesc ?? '') !== '') {
expect(owned.map((i) => i.avatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc)
}
// A second box can't roll the same prize: "an item that you don't have" excludes what
// the first roll just granted. Two draws from a 244-item pool could collide by chance,
// so this only holds because the pool is filtered by ownership.
const second = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('76')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 2,
PurchasableItemId: 539,
CurrencyType: CurrencyType.RecCenterTokens,
RequestedPrice: 800,
}),
})
expect(second.status).toBe(200)
const after = await giftBoxes('76')
expect(after).toHaveLength(2)
expect(key(after[0])).not.toBe(key(after[1]))
})
test('buying sf3s Uncommon Random box answers with the rolled item', async () => {
// The purchase that came back as an empty box: an sf3 query drop, rolled out of the very
// catalog it sells in.
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('77')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 2455,
CurrencyType: CurrencyType.RecCenterTokens,
RequestedPrice: 200,
CouponConsumablePlayerMappingId: null,
Gift: null,
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
BalanceUpdates: Array<{
Data: Array<{ Id: number; AvatarItemDesc: string; GiftRarity: number }>
}>
}
const entry = body.BalanceUpdates[0]?.Data[0]
// Uncommon: rarity 10, and a real item rather than the box's empty fields.
expect(entry?.GiftRarity).toBe(10)
expect(entry?.AvatarItemDesc).not.toBe('')
const boxes = await giftBoxes('77')
expect(boxes).toHaveLength(1)
expect(boxes[0]?.Id).toBe(entry?.Id)
expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc)
})
test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity', async () => {
const headers = {
...(await bearer('80')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const request = (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers,
body,
})
const statusOf = (rewardType: string, giftContext = '') =>
env.DB.prepare(
`SELECT granted_at, grant_count FROM reward_status
WHERE account_id = 80 AND reward_type = ?1 AND gift_context = ?2`
)
.bind(rewardType, giftContext)
.first<{ granted_at: string; grant_count: number }>()
// A claim answers the empty list the client accepts — the reward rides in a gift box.
const first = await request(
'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day'
)
expect(first.status).toBe(200)
expect(await first.json()).toEqual([])
const claimed = await statusOf('FirstActivityOfDay')
expect(claimed?.grant_count).toBe(1)
// Asking again inside the hour claims nothing — and must not push the cooldown out,
// or a client that retries in a loop would never become eligible.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect(await statusOf('FirstActivityOfDay')).toEqual(claimed)
// A different type has its own cooldown — and so does each `giftContext` within a type:
// Soccer and Paintball are separate rows that each claim once.
expect(
(
await request(
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer'
)
).status
).toBe(200)
expect((await statusOf('PostGameActivity', 'Soccer'))?.grant_count).toBe(1)
expect(
(
await request(
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Paintball'
)
).status
).toBe(200)
expect((await statusOf('PostGameActivity', 'Paintball'))?.grant_count).toBe(1)
// …but the same activity again inside the hour claims nothing.
const soccer = await statusOf('PostGameActivity', 'Soccer')
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
expect(await statusOf('PostGameActivity', 'Soccer')).toEqual(soccer)
// A contextless ask is its own bucket (`''`), not a wildcard over the two above.
expect((await request('rewardType=PostGameActivity&Message=no%20context')).status).toBe(200)
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
expect((await request('rewardType=PostGameActivity&Message=again')).status).toBe(200)
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
// Once the hour has passed, the same type claims again.
await env.DB.prepare(
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = 'FirstActivityOfDay'"
)
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
.run()
expect((await request('rewardType=FirstActivityOfDay&Message=tomorrow')).status).toBe(200)
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
})
test('a claimed game reward pays XP into a gift box, and announces it', async () => {
const request = async (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: {
...(await bearer('82')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
/** Age the cooldown so the next ask is eligible again. */
const passAnHour = () =>
env.DB.prepare(
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 82 AND reward_type = 'FirstActivityOfDay'"
)
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
.run()
await drainFrames()
expect((await getProgression(env.DB, 82)).XP).toBe(0)
const res = await request('rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day')
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// 5 XP is deliberately less than the 10 the first level costs, so one action moves the
// bar without levelling anyone up.
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// One box: the XP reward itself, carrying the message the client asked to show and no
// item — a game reward is not an item.
const first = await giftBoxes('82')
expect(first).toHaveLength(1)
expect(first[0]).toMatchObject({
Xp: 5,
Message: 'First Game of the Day',
AvatarItemDesc: '',
EquipmentModificationGuid: '',
ConsumableItemDesc: '',
})
// The box, then the bar — no level-up box, since no level was crossed.
const frames = await drainFrames()
expect(frames.map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
])
expect(frames[0]?.accountId).toBe(82)
expect(frames[0]?.payload).toMatchObject({
Id: first[0]?.Id,
FromPlayerId: 1,
Xp: 5,
// GiftContext.GameRewards — the box came from gameplay, not a purchase.
GiftContext: 50,
Message: 'First Game of the Day',
})
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
expect(await giftBoxes('82')).toHaveLength(1)
expect(await drainFrames()).toEqual([])
// A SECOND reward completes the 10 XP level 1 costs — two actions per early level, which
// is the pacing the smaller grant buys.
await passAnHour()
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
// …and level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
// equipment skin, which is what the avatar-only roll is for.
const afterLevel2 = await giftBoxes('82')
expect(afterLevel2).toHaveLength(3)
const clothingBox = afterLevel2[2]
expect(clothingBox?.Message).toBe('Level 2!')
expect(clothingBox?.AvatarItemDesc).not.toBe('')
expect(clothingBox?.EquipmentModificationGuid).toBe('')
expect(clothingBox?.ConsumableItemDesc).toBe('')
expect(clothingBox?.GiftRarity).toBe(10)
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('82'),
})
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
expect((await drainFrames()).map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
NotificationType.GiftPackageReceivedImmediate,
])
// Two more rewards reach level 3, which the table pays as a CONSUMABLE rather than
// clothing — rolled without a rarity, since the table names none for them.
for (const message of ['Third', 'Fourth']) {
await passAnHour()
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
}
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
const afterLevel3 = await giftBoxes('82')
const consumableBox = afterLevel3[afterLevel3.length - 1]
expect(consumableBox?.Message).toBe('Level 3!')
expect(consumableBox?.ConsumableItemDesc).not.toBe('')
expect(consumableBox?.AvatarItemDesc).toBe('')
expect(consumableBox?.EquipmentModificationGuid).toBe('')
const consumables = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('82'),
})
const held = (await consumables.json()) as Array<{ ConsumableItemDesc: string }>
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
})
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day',
})
expect(anon.status).toBe(401)
// No reward type: nothing to gate, so no row keyed on an empty string.
const typeless = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: {
...(await bearer('81')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'Message=First%20Game%20of%20the%20Day',
})
expect(typeless.status).toBe(200)
expect(await typeless.json()).toEqual([])
const rows = await env.DB.prepare(
'SELECT COUNT(*) AS count FROM reward_status WHERE account_id = 81'
).first<{ count: number }>()
expect(rows?.count).toBe(0)
})
test('GET /api/roomkeys/v1/mine returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/mine`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/subscriptionseasons/v1/seasons/current returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/subscriptionseasons/v1/seasons/current`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
const getSubscription = async (headers: Record<string, string> = {}) =>
exports.default.fetch(`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`, {
method: 'POST',
headers,
})
// Fixed values, and no auth: the client reads this while assembling the RR+ page, so a
// 401 would only be a way for that load to stall.
test('GET /api/incentivizedreferrals/progress reports an untouched referral track', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/incentivizedreferrals/progress`, {
headers: await bearer('207'),
})
expect(res.status).toBe(200)
// The payload is nested under `value`, unlike econ's flat balance bodies.
expect(await res.json()).toEqual({
success: true,
value: { ReferralsVerifiedCount: 0, PlayerReferralRewards: [] },
})
})
test('GET /api/incentivizedreferrals/progress 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/incentivizedreferrals/progress`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
test('GET /api/influencerpartnerprogram/influencers lists nobody', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencers?take=1000`,
{ headers: await bearer('207') }
)
expect(res.status).toBe(200)
// 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: [] })
})
test('GET /api/influencerpartnerprogram/influencers 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/influencers`)
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') }
)
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') }
)
expect(await self.json()).toBe(0)
})
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 () => {
const res = await exports.default.fetch(`${ORIGIN}/api/makerai/checkfreetrialeligibility`, {
headers: await bearer('206'),
})
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
// The whole body is the boolean — not `{ value: false }`, not an envelope.
expect(await res.text()).toBe('false')
})
test('GET /api/makerai/checkfreetrialeligibility 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/makerai/checkfreetrialeligibility`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
test('GET /api/CampusCard/v1/SignUpBonus returns the running bonus, unauthenticated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/CampusCard/v1/SignUpBonus`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
RRPlusSignUpBonusId: 3,
MinFreeItemsPrice: 6000,
MaxFreeItemsPrice: 10000,
})
})
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => {
const res = await getSubscription(await bearer('205', ['gameClient', 'developer']))
expect(res.status).toBe(200)
const body = (await res.json()) as {
Subscription: Record<string, unknown>
PlatformAccountSubscribedPlayerId: null
}
expect(body.PlatformAccountSubscribedPlayerId).toBeNull()
expect(body.Subscription).toMatchObject({
SubscriptionId: 1,
// The subscribed player is the caller, not a fixed id.
RecNetPlayerId: 205,
// -1 All: no store sold this. 0 = Gold (1 is Platinum), 1 = Year.
PlatformType: -1,
PlatformId: '',
PlatformPurchaseId: '',
Level: 0,
Period: 1,
IsAutoRenewing: true,
})
// The subscription runs a year from the call rather than to a hard-coded date, so it
// cannot lapse on a day nobody is expecting.
const created = new Date(body.Subscription.CreatedAt as string)
const expires = new Date(body.Subscription.ExpirationDate as string)
expect(body.Subscription.ModifiedAt).toBe(body.Subscription.CreatedAt)
expect(expires.getTime()).toBeGreaterThan(Date.now())
expect(expires.getUTCFullYear()).toBe(created.getUTCFullYear() + 1)
expect(expires.getUTCMonth()).toBe(created.getUTCMonth())
expect(expires.getUTCDate()).toBe(created.getUTCDate())
})
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => {
// A plain player's token: valid, but no elevated role.
expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({})
// A token with no `role` claim at all.
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
// No token: "not subscribed" rather than 401, so a loading client isn't stalled.
const anon = await getSubscription()
expect(anon.status).toBe(200)
expect(await anon.json()).toEqual({})
})
test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
// `.on(['GET','POST'], …)` cleargroup route contributes both methods.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /api/CampusCard/v1/SignUpBonus',
'GET /api/avatar/v1/defaultbaseavataritems',
'GET /api/avatar/v1/defaultunlocked',
'GET /api/avatar/v2',
'GET /api/avatar/v2/gifts',
'GET /api/avatar/v2/{id}',
'GET /api/avatar/v3/saved',
'GET /api/avatar/v4/items',
'GET /api/challenge/v2/getCurrent',
'GET /api/checklist/v1/current',
'GET /api/checklist/v2/current',
'GET /api/consumables/v2/getUnlocked',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gamerewards/v1/pending',
'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',
'GET /api/objectives/v1/cleargroup',
'GET /api/objectives/v1/myprogress',
'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}',
'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}/me',
'GET /api/roomcurrencies/v1/currencies',
'GET /api/roomcurrencies/v1/getAllBalances',
'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room',
'GET /api/storefronts/v1/adcarouselitems',
'GET /api/storefronts/v2/buyInvention',
'GET /api/storefronts/v3/giftdropstore/{id}',
'GET /api/storefronts/v4/balance/{currencyType}',
'GET /api/subscriptionseasons/v1/seasons/current',
'GET /api/ugcPurchasables/v1/items/room/{roomId}',
'GET /econ/customAvatarItems/v1/owned',
'GET /econ/roomEconConfig/{roomId}',
'GET /econ/roomGiftDropShops/room/{roomId}',
'GET /econ/roomInventory/room/{roomId}',
'GET /econ/roomInventory/room/{roomId}/player',
'GET /econ/roomInventoryItemTags/room/{roomId}',
'GET /econ/roomOffer/room/{roomId}',
'GET /econ/roomOffer/room/{roomId}/purchaseCounts',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/avatar/v2/gifts/consume',
'POST /api/avatar/v2/set',
'POST /api/avatar/v3/saved/set',
'POST /api/avatar/v4/saved/set',
'POST /api/challenge/v2/updateProgress',
'POST /api/checklist/v1/complete',
'POST /api/checklist/v2/complete',
'POST /api/consumables/v1/consume',
'POST /api/gamerewards/v1/request',
'POST /api/items/bulkpurchase',
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
'POST /api/storefronts/v2/buyItem',
'PUT /api/equipment/v1/update',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})