Files
recflare/apps/econ/src/test/integration/api.test.ts
T
2026-08-11 13:10:31 -04:00

2014 lines
78 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,
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 { OUTFIT_SCHEMA_DDL } from '../../outfit-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(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
)}`
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 is an empty stub (no auth)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultbaseavataritems`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
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 returns the item catalog with a valid token', 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 unknown[]
expect(Array.isArray(body)).toBe(true)
expect(body.length).toBeGreaterThan(0)
expect(body[0]).toHaveProperty('AvatarItemDesc')
expect(body[0]).toHaveProperty('FriendlyName')
})
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/current 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
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/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([])
})
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)
})
/**
* 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)
})
/**
* 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 whole point of the threshold: the gift lands before the set is finished (the
// published week is five challenges for three).
expect(REQUIRED_FOR_GIFT).toBeLessThan(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'),
})
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', 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) =>
env.DB.prepare(
'SELECT granted_at, grant_count FROM reward_status WHERE account_id = 80 AND reward_type = ?1'
)
.bind(rewardType)
.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; `giftContext` doesn't split it.
expect(
(
await request(
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer'
)
).status
).toBe(200)
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
expect(
(
await request(
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Paintball'
)
).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'),
})
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('POST /api/CampusCard/v1/UpdateAndGetSubscription returns null fields', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`,
{
method: 'POST',
}
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
subscription: null,
platformAccountSubscribedPlayerId: null,
})
})
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/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/consumables/v2/getUnlocked',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gamerewards/v1/pending',
'GET /api/itemWishlists/v1/wishlist/me',
'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 /econ/customAvatarItems/v1/owned',
'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/consumables/v1/consume',
'POST /api/gamerewards/v1/request',
'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()
}
})
})