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 {
	createCustomAvatarItem,
	SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
} from '../../../../api/src/custom-avatar-items-db'
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'
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'
// The live weekly rotation, generated the same way the worker generates it, so the challenge
// tests exercise whatever this week actually holds instead of ids from a rotation that has
// since rolled over.
import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-rotation'
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'

/** This week's rotation — the same one the worker builds for these requests. */
const weekly = buildRotation(new Date())

/** The first challenge of the live rotation — the progress tests report against it. */
const CURRENT_CHALLENGE = weekly.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()
	for (const stmt of CUSTOM_AVATAR_ITEM_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 player’s 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('POST /api/ugcPurchasables/v1/items/bulk resolves custom avatar items, echoing RoomId', async () => {
		const item = await createCustomAvatarItem(env.DB, {
			customAvatarItemId: crypto.randomUUID(),
			creatorAccountId: 205,
			name: 'Neon Visor',
			description: '',
			price: 250,
			baseAvatarItemId: 1,
			baseAvatarItemColor: '#fff',
			accessibility: 0,
			designFilename: 'design_x.bin',
			thumbnailImageFilename: 'thumb_x.png',
		})
		const res = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
			method: 'POST',
			headers: { ...(await bearer()), 'content-type': 'application/json' },
			body: JSON.stringify({
				RoomId: 92,
				Ids: [
					{ itemType: 3, itemId: item.CustomAvatarItemId },
					{ itemType: 3, itemId: 'does-not-exist' },
					{ itemType: 1, itemId: item.CustomAvatarItemId },
				],
			}),
		})
		expect(res.status).toBe(200)
		expect(await res.json()).toEqual([
			{
				ItemType: 3,
				ItemId: item.CustomAvatarItemId,
				Name: 'Neon Visor',
				Description: '',
				ImageName: 'thumb_x.png',
				RoomId: 92,
				Price: 250,
				PurchaseCurrencyId: null,
				CreatedAt: item.CreatedAt,
				ModifiedAt: item.ModifiedAt,
			},
		])
	})

	test('POST /api/ugcPurchasables/v1/items/bulk 400s without Ids and 401s without a token', async () => {
		const bad = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
			method: 'POST',
			headers: { ...(await bearer()), 'content-type': 'application/json' },
			body: JSON.stringify({ RoomId: 92 }),
		})
		expect(bad.status).toBe(400)
		const anon = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({ RoomId: 92, Ids: [] }),
		})
		expect(anon.status).toBe(401)
	})

	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, method: 'PUT' | 'POST' = 'PUT') =>
			exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
				method,
				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)

		// The client sends this as a POST too, with the same body — same effect.
		expect((await update(true, 'POST')).status).toBe(200)
		expect((await unlocked())[0]?.Favorited).toBe(true)
		expect((await update(false, 'POST')).status).toBe(200)
		expect((await unlocked())[0]?.Favorited).toBe(false)
	})

	test('POST /api/equipment/v1/update favourites from the client’s own body', async () => {
		// The body verbatim as the client sends it — a full echo of the entry it was served,
		// of which only `Favorited` is read.
		const post = async (favorited: boolean, sub = '33') =>
			exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
				method: 'POST',
				headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
				body: JSON.stringify([
					{
						PrefabName: '[ShareCamera]',
						ModificationGuid: 'g5u0weNLmkCLeUXFUVn74Q',
						FriendlyName: 'Camera Skin (Comic)',
						Tooltip: 'ShareCamera Comic Debug: 2121',
						Rarity: 5,
						Favorited: favorited,
					},
				]),
			})

		// Nothing owned yet: the guid matches no row, so this is a silent no-op, not an error.
		expect((await post(true)).status).toBe(200)

		await grantEquipment(env.DB, 33, {
			PrefabName: '[ShareCamera]',
			ModificationGuid: 'g5u0weNLmkCLeUXFUVn74Q',
			FriendlyName: 'Camera Skin (Comic)',
			Tooltip: 'ShareCamera Comic Debug: 2121',
			Rarity: 5,
			PlatformMask: -1,
			Favorited: false,
		})
		const owned = async () => {
			const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
				headers: await bearer('33'),
			})
			return (await res.json()) as Array<{ ModificationGuid: string; Favorited: boolean }>
		}
		expect((await owned())[0]?.Favorited).toBe(false)

		expect((await post(true)).status).toBe(200)
		expect((await owned())[0]?.Favorited).toBe(true)
		expect((await post(false)).status).toBe(200)
		expect((await owned())[0]?.Favorited).toBe(false)
	})

	test('equipment/v1/update 401s without a token, 400s on a non-array body (PUT and POST)', async () => {
		for (const method of ['PUT', 'POST'] as const) {
			const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
				method,
				headers: { 'Content-Type': 'application/json' },
				body: '[]',
			})
			expect(anon.status).toBe(401)

			const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
				method,
				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 }])
	})

	// sf300's item 2263 is 95 tokens in `Prices` and 85 in `SubscriberPrices`. A subscriber's
	// client renders and posts the 85, so the check has to read the list the buyer sees.
	const buy2263 = async (headers: Record<string, string>, RequestedPrice: number) =>
		exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
			method: 'POST',
			headers: { ...headers, 'Content-Type': 'application/json' },
			body: JSON.stringify({
				StorefrontType: 300,
				PurchasableItemId: 2263,
				CurrencyType: 2,
				RequestedPrice,
				CouponConsumablePlayerMappingId: null,
				Gift: null,
			}),
		})

	test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => {
		await drainFrames()
		const res = await buy2263(await bearer('322', ['gameClient', 'developer']), 85)
		expect(res.status).toBe(200)
		expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
		const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
			headers: await bearer('322'),
		})
		expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 85 }])
	})

	test('POST /api/storefronts/v2/buyItem derives the subscriber price when the catalog lists none', async () => {
		// sf3's item 2184 is 95 in BOTH lists (captured through a non-subscriber's view), but a
		// subscriber's client still posts floor(95 * 0.9) = 85.
		const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
			method: 'POST',
			headers: {
				...(await bearer('325', ['gameClient', 'developer'])),
				'Content-Type': 'application/json',
			},
			body: JSON.stringify({
				StorefrontType: 3,
				PurchasableItemId: 2184,
				CurrencyType: 2,
				RequestedPrice: 85,
				CouponConsumablePlayerMappingId: null,
				Gift: null,
			}),
		})
		expect(res.status).toBe(200)
		expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
	})

	test('POST /api/storefronts/v2/buyItem 409s a subscriber posting the regular price', async () => {
		const res = await buy2263(await bearer('323', ['gameClient', 'developer']), 95)
		expect(res.status).toBe(409)
	})

	test('POST /api/storefronts/v2/buyItem 409s a non-subscriber posting the subscriber price', async () => {
		const res = await buy2263(await bearer('324'), 85)
		expect(res.status).toBe(409)
		const ok = await buy2263(await bearer('324'), 95)
		expect(ok.status).toBe(200)
	})

	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 buyer’s 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.ChallengeMapId).toBe(weekly.ChallengeMapId)
		expect(body.Challenges).toHaveLength(weekly.Challenges.length)
	})

	test('the rotation is a pure function of the week', async () => {
		const at = new Date('2026-08-25T12:00:00Z')
		// Same instant, same rotation — and any instant in the same week, too. Two players
		// served different challenges for one `ChallengeMapId` would disagree about who has
		// finished the week.
		expect(buildRotation(at)).toEqual(buildRotation(at))
		const laterSameWeek = new Date('2026-08-26T20:59:59Z')
		expect(rotationIndex(laterSameWeek)).toBe(rotationIndex(at))
		expect(buildRotation(laterSameWeek).Challenges).toEqual(buildRotation(at).Challenges)

		// …and the week rolls at Wednesday 21:00 UTC, one map id at a time.
		const nextWeek = new Date('2026-08-26T21:00:00Z')
		expect(rotationIndex(nextWeek)).toBe(rotationIndex(at) + 1)
		expect(buildRotation(nextWeek).ChallengeMapId).toBe(buildRotation(at).ChallengeMapId + 1)
		expect(buildRotation(nextWeek).StartAt).toBe(buildRotation(at).EndAt)
	})

	test('the week is themed on the name of the item it rolls', async () => {
		// `ChallengeThemeString` is the reward's catalog FriendlyName. The static file ships it
		// empty on purpose — a generated week's gift isn't known until it is rolled — so the
		// theming happens where the pick does.
		const pool = [
			{
				GiftDropId: 11,
				EquipmentPrefabName: '[ShareCamera]',
				EquipmentModificationGuid: 'guid-a',
				Rarity: 30,
				FriendlyName: 'Camera Skin (Comic)',
			},
			{
				GiftDropId: 12,
				EquipmentPrefabName: '[Boombox]',
				EquipmentModificationGuid: 'guid-b',
				Rarity: 20,
				FriendlyName: 'Boombox (Neon)',
			},
		]
		const at = new Date('2026-08-25T12:00:00Z')
		const themed = withWeeklyGift(buildRotation(at), pool)
		const rolled = pool.find((p) => p.GiftDropId === themed.Gift.GiftDropId)
		expect(rolled).toBeDefined()
		expect(themed.ChallengeThemeString).toBe(rolled!.FriendlyName)

		// An empty pool (the catalog didn't load) leaves the rotation as it was rather than
		// theming the week on nothing.
		expect(withWeeklyGift(buildRotation(at), []).ChallengeThemeString).toBe(
			buildRotation(at).ChallengeThemeString
		)

		// And over the live catalog the route serves a real name, not the placeholder.
		const served = (await (
			await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
		).json()) as { ChallengeThemeString: string }
		expect(served.ChallengeThemeString).not.toBe('')
	})

	test('every generated week is five valid, distinct challenges', async () => {
		// Walk two years of rotations: the pool, the constraints and the tree builders all have
		// to hold for every week, not just this one.
		for (let week = 0; week < 104; week++) {
			const at = new Date(Date.UTC(2026, 0, 7, 21, 0, 0) + week * 7 * 24 * 60 * 60 * 1000)
			const rotation = buildRotation(at)
			const where = `week ${week}`
			expect(rotation.Challenges, where).toHaveLength(5)
			// Ids have to be unique within a rotation — `challenge_status` is keyed by them.
			const ids = rotation.Challenges.map((ch) => ch.ChallengeId)
			expect(new Set(ids).size, where).toBe(ids.length)
			// One room per week: five ways to say "play Paintball" is not a rotation.
			const scenes = rotation.Challenges.flatMap((ch) => sceneIdsOf(ch.Config))
			expect(new Set(scenes).size, where).toBe(scenes.length)
			for (const challenge of rotation.Challenges) {
				// A malformed tree fails SILENTLY in the client — the challenge just never
				// completes — so the shape is asserted here rather than discovered in game.
				const tree = JSON.parse(challenge.Config) as { ct: number; t?: number }
				expect([0, 1], `${where} ${challenge.Name}`).toContain(tree.ct)
				if (tree.ct === 1) expect(tree.t, `${where} ${challenge.Name}`).toBeGreaterThan(0)
				expect(sceneIdsOf(challenge.Config).length, `${where} ${challenge.Name}`).toBeGreaterThan(0)
				// The copy is generated from the same inputs as the tree, so it can't drift — but a
				// counter still has to say out loud how far it counts.
				if (tree.ct === 1) expect(challenge.Description, where).toContain(String(tree.t))
				expect(challenge.Tooltip.length, `${where} ${challenge.Name}`).toBeGreaterThan(0)
				expect(challenge.Complete, `${where} ${challenge.Name}`).toBe(false)
			}
		}
	})

	/** Every `ct:7` scene id in a rule tree, however deep the tree nests them. */
	function sceneIdsOf(config: string): string[] {
		const scenes: string[] = []
		const walk = (node: unknown): void => {
			if (Array.isArray(node)) return node.forEach(walk)
			if (node === null || typeof node !== 'object') return
			const record = node as { ct?: number; vs?: Array<{ l?: string }> }
			if (record.ct === 7)
				for (const value of record.vs ?? []) if (value.l !== undefined) scenes.push(value.l)
			for (const value of Object.values(record)) walk(value)
		}
		walk(JSON.parse(config))
		return scenes
	}

	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(weekly.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: weekly.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(weekly.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('78')
		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(weekly.ChallengeMapId),
					ChallengeId: String(challenge.ChallengeId),
					...body,
				}),
			})
		const reported = await post({ Config: inProgress, Complete: 'False' })
		expect(await reported.json()).toEqual({
			ChallengeMapId: weekly.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(
			weekly.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 = weekly.CompletedRequired
		? weekly.Challenges.length
		: Math.min(3, weekly.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 = weekly.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(weekly.ChallengeMapId),
					ChallengeId: String(challengeId),
					Complete: 'True',
				}),
			})
		return { ids, report }
	}

	/**
	 * The reward this week advertises, read back from the route that shows it to the client.
	 * The gift is rolled from the storefront catalog rather than authored, so the assertion
	 * that matters is that the box a player receives carries what the rotation promised.
	 */
	async function advertisedGift() {
		const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
		return ((await res.json()) as { Gift: Record<string, string & number> }).Gift
	}

	/** 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 this week generated.
		const gift = await advertisedGift()
		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(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: gift.AvatarItemDesc,
			AvatarItemType: gift.AvatarItemType,
			EquipmentPrefabName: gift.EquipmentPrefabName,
			EquipmentModificationGuid: gift.EquipmentModificationGuid,
			CurrencyType: 0,
			Currency: 0,
			Xp: 0,
			Level: 0,
			Platform: -1,
			PlatformsToSpawnOn: -1,
			BalanceType: -2,
			GiftContext: gift.GiftContext,
			// The catalog's rarity for the item — which is also what the generated block carries,
			// since the week's gift is drawn from the catalog itself.
			GiftRarity: gift.GiftRarity,
			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(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 rotation’s gift rolls the fallback box instead', async () => {
		// Own the reward up front — the case the rotation's `FallbackGiftName` exists for.
		const gift = await advertisedGift()
		await grantEquipment(env.DB, 75, {
			ModificationGuid: gift.EquipmentModificationGuid,
			PrefabName: gift.EquipmentPrefabName,
			FriendlyName: 'The week’s reward, already owned',
			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(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 buyer’s 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 sf3’s 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/equipment/v1/update',
			'POST /api/gamerewards/v1/request',
			'POST /api/items/bulkpurchase',
			'POST /api/objectives/v1/cleargroup',
			'POST /api/objectives/v1/updateobjective',
			'POST /api/storefronts/v2/buyItem',
			'POST /api/ugcPurchasables/v1/items/bulk',
			'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()
		}
	})
})
