Files
recflare/apps/api/src/test/integration/api.test.ts
T

3019 lines
119 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import {
GAME_VERSION,
grantInvention,
INVENTORY_INVENTION_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import '../../api.app'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
getEventAttendees,
getEventResponse,
} from '../../events-db'
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-db'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { Env } from '../../context'
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
import type { SavedImage } from '../../images-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
// `/api/rooms/v1/verifyRole` reads room roles from the shared recflare D1. Set
// up the schema (matching the rooms worker's migration) + a couple of rooms.
const TEST_ROOMS = [
{
RoomId: 2,
Name: 'RecCenter',
IsDorm: false,
CreatorAccountId: 1,
SubRooms: [{ SubRoomId: 2 }],
},
{
// Owned by account 1; account 42 holds Role 30 (a co-owner) for verifyRole tests.
RoomId: 3,
Name: 'RoleRoom',
IsDorm: false,
CreatorAccountId: 1,
SubRooms: [{ SubRoomId: 3 }],
Roles: [{ AccountId: 42, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
},
]
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')
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
// split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration).
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
// Accounts table (matching the auth worker's migration) — uploadsaved records
// profile thumbnails on the account row. Seed the account the test token (sub
// 42) authenticates as.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS account (
data TEXT NOT NULL,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
)`
).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({ accountId: 42, username: 'Tester', profileImage: 'DefaultProfileImage.jpg' })
)
.run()
// Images table (owned by the img worker) — uploadsaved records a row here.
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Relationships table (owned by the api worker) — friendship endpoints use it.
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Inventions table (owned by the api worker) — invention save/mine use it.
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in.
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Reports table (owned by the api worker) — player reports are recorded here.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Player events table (owned by the api worker) — scheduled events live here.
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
// api worker's validation accepts it. Kept inline to avoid a cross-package import.
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(/=+$/, '')
}
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none, which is what a plain player's looks like to the
// role-gated routes.
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
)}`
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)}` }
}
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
async function base64Sha256(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
}
describe('public endpoints', () => {
test('GET /api/config/v1/amplitude', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false,
})
})
test('GET /api/config/v1/azurespeech', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/azurespeech`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
})
test('GET /api/config/v1/backtrace', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/backtrace`)
expect(res.status).toBe(200)
const body = (await res.json()) as { ReportBudget: number; VersionRegex: string }
expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' })
})
test('GET /api/versioncheck/v4 reports current for the matching build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${GAME_VERSION}`)
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
})
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
headers: await bearer('99999'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/playerReputation/v1/:id echoes the id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v1/99`)
expect(await res.json()).toMatchObject({ AccountId: 99, CheerCredit: 20 })
})
test('GET /api/playerReputation/v2/bulk?id= returns a reputation per id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk?id=1380`)
expect(res.status).toBe(200)
// The full reputation shape the client expects, field for field.
expect(await res.json()).toEqual([
{
AccountId: 1380,
IsCheerful: true,
Noteriety: 0,
SelectedCheer: 0,
CheerCredit: 20,
CheerGeneral: 0,
CheerHelpful: 0,
CheerCreative: 0,
CheerGreatHost: 0,
CheerSportsman: 0,
SubscriberCount: 0,
SubscribedCount: 0,
},
])
const many = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk?id=1&id=2`)
const reps = (await many.json()) as Array<{ AccountId: number }>
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
})
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
expect(res.status).toBe(200)
const words = (await res.json()) as Array<{ Id: number; Difficulty: number; EN_US: string }>
expect(Array.isArray(words)).toBe(true)
expect(words.length).toBeGreaterThan(0)
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
})
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
)
expect(res.status).toBe(200)
// ReportCategory -1 = no category (0 is a real one), and Message is null.
expect(await res.json()).toEqual({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
})
// Unauthenticated by design — the client posts this before it has an account, so
// there's no bearer token to check and nothing to attribute the id to.
test('POST /api/PlayerReporting/v1/deviceId accepts an unauthenticated report', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/deviceId`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
oldDeviceId: '491e8b9',
newDeviceId: '491e8b9566cb1b593367c72860e978b3d5765326',
platform: '0',
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ Ids: '1,2,3' }),
})
expect(res.status).toBe(200)
const reps = (await res.json()) as Array<{ AccountId: number; CheerCredit: number }>
expect(reps.map((r) => r.AccountId)).toEqual([1, 2, 3])
expect(reps.every((r) => r.CheerCredit === 20)).toBe(true)
})
test('POST /api/playerReputation/v2/bulk returns [] without ids', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk`, {
method: 'POST',
})
expect(await res.json()).toEqual([])
})
test('GET /api/players/v2/progression/bulk?id= returns progression per id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk?id=1&id=2`)
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ PlayerId: number; Level: number }>
expect(body.map((p) => p.PlayerId)).toEqual([1, 2])
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
})
test('POST /api/players/v2/progression/bulk returns an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ Ids: '1,2,3' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/customAvatarItems/v1/isCreationAllowedForAccount returns a success envelope', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/isCreationAllowedForAccount`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, value: null })
})
test('GET /api/customAvatarItems/v1/isCreationEnabled returns true', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/isCreationEnabled`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
})
test('GET /api/customAvatarItems/v1/isRenderingEnabled returns true', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/isRenderingEnabled`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
})
test('GET /api/customAvatarItems/v1/featured returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/customAvatarItems/v1/hot returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`)
expect(res.status).toBe(200)
const body = (await res.json()) as { PinnedFilters: string[]; PopularFilters: string[] }
expect(Array.isArray(body.PinnedFilters)).toBe(true)
expect(Array.isArray(body.PopularFilters)).toBe(true)
})
test('GET /api/keepsakes/globalconfig returns the keepsake config', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/keepsakes/globalconfig`)
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({ KeepsakeFeatureEnabled: true })
})
test('GET /api/keepsakes/rooms/:id returns 204; categories returns []', async () => {
const room = await exports.default.fetch(`${ORIGIN}/api/keepsakes/rooms/1`)
expect(room.status).toBe(204)
const cats = await exports.default.fetch(`${ORIGIN}/api/keepsakes/categories`)
expect(cats.status).toBe(200)
expect(await cats.json()).toEqual([])
})
test('GET /voice/config returns an object', async () => {
const res = await exports.default.fetch(`${ORIGIN}/voice/config`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
test('GET /api/inventions/v2/mine 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`)
expect(res.status).toBe(401)
})
test('GET /api/inventions/v2/mine returns [] for a player with none', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('7777'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /api/inventions/v6/save persists the invention and lists it in mine', async () => {
const body = {
name: '071126 13:10:50',
description: 'No description yet',
imageName: '2026-07-11/0ff3d5f9-e544-422d-84a0-dec46195a82b.jpg',
instantiationCost: 103,
lightsCost: 0,
chipsCost: 0,
cloudVariablesCost: 0,
aiCost: 0,
creationRoomId: 73,
inventionDataFilename: '2026-07-11/cc15a7fa-2e81-4da0-b8f1-2a4dcd8ae1a3',
referencedInventions: [],
creatorAccountRole: 255,
}
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect(res.status).toBe(200)
// Save answers with the `{ Status, Invention, InventionVersion }` envelope —
// the version sits alongside the invention, not only nested inside it.
const result = (await res.json()) as InventionSaveResult
expect(result.Status).toBe(0)
const saved = result.Invention
expect(saved.InventionId).toBeGreaterThan(0)
expect(saved.CreatorPlayerId).toBe(5150)
expect(saved.Name).toBe(body.name)
expect(saved.Description).toBe(body.description)
expect(saved.ImageName).toBe(body.imageName)
// Costs + the data blob live on the version. The blob name always carries the
// `.inv` extension the client expects, whether or not the client sent it.
expect(result.InventionVersion).toMatchObject({
InventionId: saved.InventionId,
VersionNumber: 1,
InstantiationCost: 103,
LightsCost: 0,
BlobName: `${body.inventionDataFilename}.inv`,
})
expect(saved.CurrentVersion.BlobName).toBe(`${body.inventionDataFilename}.inv`)
// An extension the client already supplied isn't doubled up.
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
})
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
'2026-07-12/x.inv'
)
expect(saved.CreationRoomId).toBe(73)
// Fully permissioned from the start (the client's creatorAccountRole is a room
// role, not an invention permission, so it's ignored); publishing is what
// narrows GeneralPermission down.
expect(saved.CreatorPermission).toBe(100)
expect(saved.GeneralPermission).toBe(100)
expect(saved.AllowTrial).toBe(true)
// Freshly saved → private/unpublished until the player publishes it.
expect(saved.IsPublished).toBe(false)
expect(saved.FirstPublishedAt).toBeNull()
expect(typeof saved.CreatedAt).toBe('string')
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5150'),
})
expect(mine.status).toBe(200)
const list = (await mine.json()) as SavedInvention[]
expect(list.map((i) => i.InventionId)).toContain(saved.InventionId)
// The saved invention is fetchable by id via the v1 lookup.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${saved.InventionId}`
)
expect(one.status).toBe(200)
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the callers own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
const save = async (sub: string, name: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
})
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention
}
const mine = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as SavedInvention[]
}
const bought = await save('6100', 'bought-invention')
const own = await save('6101', 'own-invention')
await grantInvention(env.DB, 6101, bought.InventionId)
// Newest first, whichever set it came from: 6101 saved theirs after buying.
const list = await mine('6101')
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
// A bought invention is still the creator's — it is listed, not re-attributed.
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
// The seller's own list is unaffected by the sale.
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
// An ownership row pointing at an invention that no longer exists just drops out.
await grantInvention(env.DB, 6101, 999_888)
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
own.InventionId,
bought.InventionId,
])
})
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'x', inventionDataFilename: 'a.inv' }),
})
expect(res.status).toBe(401)
})
test('POST /api/inventions/v6/save 400s without the invention data blob', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'no blob' }),
})
expect(res.status).toBe(400)
})
test('POST /api/inventions/v6/save defaults a missing name and description', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6161')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: ' ' }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveResult).Invention).toMatchObject({
Name: 'Untitled',
Description: 'No description yet',
})
})
test('POST /api/inventions/v6/save enforces the name and description rules', async () => {
const save = async (fields: Record<string, unknown>): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6262')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
})
// A name is 324 characters of letters, digits, spaces, dashes and colons.
expect((await save({ name: 'ab' })).status).toBe(400)
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
expect((await save({ name: 'Rocket!' })).status).toBe(400)
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
expect(ok.status).toBe(200)
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
// The rejection carries the player-facing sentence, not a code.
const short = await save({ name: 'ab' })
expect((await short.json()) as { error: string }).toEqual({
error: 'Invention names must be at least 3 characters.',
})
// A description is prose: any characters, at most 512 of them.
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
expect((await save({ name: 'Punctuated', description: 'Yes! Its 100% good.' })).status).toBe(
200
)
})
test('POST /api/inventions/v6/save accepts the clients auto-generated timestamp name', async () => {
// The real client names an unnamed invention after the moment it was saved
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
// game makes — this test is what would catch that.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
})
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
expect(res.status).toBe(404)
})
test('GET /api/inventions/v1/details returns the tag list; 404s on an unknown id', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6060')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Tagless Sofabed', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
// A freshly saved invention is untagged until settags writes to it.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Tags: [] })
// Tags stored on the record are echoed back under `Tags`.
const tagged = { ...Invention, InventionId: 5150, Tags: [{ Tag: 'medium', Type: 1 }] }
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(tagged))
.run()
const withTags = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=5150`
)
expect(await withTags.json()).toEqual({ Tags: [{ Tag: 'medium', Type: 1 }] })
const unknown = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=999999`
)
expect(unknown.status).toBe(404)
})
test('POST /api/inventions/v1/settags tags the invention; details serves them back', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('4242')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Sofabed', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const settags = async (body: unknown, sub = '4242'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v1/settags`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
// Custom tags are Type 0, auto tags Type 2.
const res = await settags({
InventionId: Invention.InventionId,
AutoTags: ['lowink'],
CustomTags: ['blah'],
})
expect(res.status).toBe(200)
// settags answers the flat list of tag *names*, auto first, then custom.
expect(await res.json()).toEqual({ Result: 0, Tags: ['lowink', 'blah'] })
// details serves the typed objects: custom is Type 0, auto is Type 2.
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(await details.json()).toEqual({
Tags: [
{ Tag: 'lowink', Type: 2 },
{ Tag: 'blah', Type: 0 },
],
})
// Both lists are replaced wholesale, and tags are normalized + de-duplicated.
const replaced = await settags({
InventionId: Invention.InventionId,
AutoTags: [],
CustomTags: ['Modern', ' modern ', 'Bed'],
})
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
// A tag is at most 15 letters once lowercased. One bad tag in either list fails the
// whole call — nothing is dropped silently — and leaves the stored tags alone.
const punctuated = await settags({
InventionId: Invention.InventionId,
CustomTags: ['racing', 'Cool Stuff!'],
})
expect(punctuated.status).toBe(400)
expect((await punctuated.json()) as { error: string }).toEqual({
error: 'Invention tags can only contain letters. (“cool stuff!”)',
})
expect(
(await settags({ InventionId: Invention.InventionId, AutoTags: ['a'.repeat(16)] })).status
).toBe(400)
expect(
(await settags({ InventionId: Invention.InventionId, CustomTags: ['tag2'] })).status
).toBe(400)
const stillThere = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(await stillThere.json()).toEqual({
Tags: [
{ Tag: 'modern', Type: 0 },
{ Tag: 'bed', Type: 0 },
],
})
// Blank entries are skipped rather than rejected: the store already drops them.
const padded = await settags({
InventionId: Invention.InventionId,
CustomTags: ['modern', '', ' '],
})
expect(await padded.json()).toEqual({ Result: 0, Tags: ['modern'] })
// Only the creator may retag; unknown inventions 404; no token → 401.
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
expect(notMine.status).toBe(403)
const unknown = await settags({ InventionId: 999999, CustomTags: ['x'] })
expect(unknown.status).toBe(404)
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/settags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: Invention.InventionId, CustomTags: ['x'] }),
})
expect(anon.status).toBe(401)
})
test('GET /api/inventions/v2/search returns published inventions, filtered by value', async () => {
// Only published inventions are searchable, and nothing published exists via
// the save path (a fresh save is private), so seed the rows directly.
const published = (id: number, name: string, description: string): SavedInvention =>
({
InventionId: id,
ReplicationId: crypto.randomUUID(),
CreatorPlayerId: 8080,
Name: name,
Description: description,
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
HideFromPlayer: false,
CreatedAt: `2026-07-0${id}T00:00:00Z`,
}) as unknown as SavedInvention
for (const inv of [
published(101, 'Modern Sofabed', 'Stylistic modern bed'),
published(102, 'Racing Game', 'A retro inspired TV gaming set'),
// Unpublished + hidden rows must stay out of the results.
{ ...published(103, 'Secret Sofabed', ''), IsPublished: false },
{ ...published(104, 'Hidden Sofabed', ''), HideFromPlayer: true },
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
// No `value` → browse everything published, newest first.
const all = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?skip=0&take=100`)
expect(all.status).toBe(200)
expect(((await all.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([102, 101])
// `value` matches name or description, case-insensitively.
const hit = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent('modern sofabed')}`
)
expect(((await hit.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([101])
// skip/take paginate the published set.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?skip=1&take=1`)
expect(((await page.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([101])
const miss = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?value=nomatch`)
expect(await miss.json()).toEqual([])
})
test('GET /api/inventions/v1/tagfilters ranks the tags in use', async () => {
// Two published inventions tagged `furniture`, one `bed` — plus a tagged draft,
// whose tags must not leak into the public filter chips.
const make = async (name: string, tags: string[], publish: boolean): Promise<void> => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('9090')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/settags`, {
method: 'POST',
headers: { ...(await bearer('9090')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: Invention.InventionId, CustomTags: tags }),
})
if (publish) {
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
{ headers: await bearer('9090') }
)
}
}
await make('Filter Sofa', ['furniture', 'bed'], true)
await make('Filter Chair', ['furniture'], true)
await make('Filter Draft', ['secrettag'], false)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/tagfilters`)
expect(res.status).toBe(200)
const filters = (await res.json()) as {
PinnedFilters: string[]
PopularFilters: string[]
TrendingFilters: null
}
// Most-used tag first, and the draft's tag is nowhere to be seen.
expect(filters.PopularFilters.slice(0, 2)).toEqual(['furniture', 'bed'])
expect(filters.PopularFilters).not.toContain('secrettag')
expect(filters.PinnedFilters).toEqual(filters.PopularFilters.slice(0, 5))
expect(filters.TrendingFilters).toBeNull()
})
test('GET /api/inventions/v2/batch returns the requested inventions', async () => {
const save = async (name: string): Promise<SavedInvention> => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5566')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
return ((await res.json()) as InventionSaveResult).Invention
}
const batch = async (query: string, sub?: string): Promise<SavedInvention[]> => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/batch?${query}`, {
headers: sub === undefined ? {} : await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as SavedInvention[]
}
const first = await save('Batch One')
const draft = await save('Batch Draft')
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${first.InventionId}`,
{
headers: await bearer('5566'),
}
)
// Repeated ids and comma-separated ids both work, and order is preserved.
const ids = await batch(`id=${first.InventionId}&id=${first.InventionId}`)
expect(ids.map((i) => i.InventionId)).toEqual([first.InventionId, first.InventionId])
const commaSeparated = await batch(`id=${first.InventionId},999999`)
expect(commaSeparated.map((i) => i.InventionId)).toEqual([first.InventionId])
// The draft is hidden from everyone but its creator.
expect((await batch(`id=${draft.InventionId}`)).map((i) => i.InventionId)).toEqual([])
expect((await batch(`id=${draft.InventionId}`, '9999')).map((i) => i.InventionId)).toEqual([])
expect((await batch(`id=${draft.InventionId}`, '5566')).map((i) => i.InventionId)).toEqual([
draft.InventionId,
])
// No ids at all → an empty list, not an error.
expect(await batch('')).toEqual([])
})
test('GET /api/inventions/v1/room lists a rooms published inventions', async () => {
// Two inventions created in room 76, one of them still a draft.
const create = async (name: string, room: number): Promise<SavedInvention> => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('8484')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, creationRoomId: room, inventionDataFilename: 'a.inv' }),
})
return ((await res.json()) as InventionSaveResult).Invention
}
const publish = async (id: number): Promise<void> => {
await exports.default.fetch(`${ORIGIN}/api/inventions/v3/publish?inventionId=${id}`, {
headers: await bearer('8484'),
})
}
const inRoom = await create('Room Lamp', 76)
const draft = await create('Draft Lamp In Room', 76)
const otherRoom = await create('Other Room Lamp', 77)
await publish(inRoom.InventionId)
await publish(otherRoom.InventionId)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=76`)
expect(res.status).toBe(200)
const ids = ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
expect(ids).toEqual([inRoom.InventionId])
// The unpublished one and the other room's are both excluded.
expect(ids).not.toContain(draft.InventionId)
expect(ids).not.toContain(otherRoom.InventionId)
// A room with no inventions is an empty list, not a 404.
const empty = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=999`)
expect(await empty.json()).toEqual([])
const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room`)
expect(noId.status).toBe(400)
})
test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => {
// No cheer storage yet, so nobody is ever cheering — signed in or not.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, {
headers: await bearer('42'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ IsCheering: false })
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`)
expect(anon.status).toBe(200)
expect(await anon.json()).toEqual({ IsCheering: false })
})
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
// The data file is uploaded (via the storage worker) before the metadata save,
// so the version carries its hash from the start. No sha256 recorded on this
// object — the api worker digests the blob itself in that case.
const data = new Uint8Array([1, 2, 3, 4])
await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data)
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Versioned Lamp',
instantiationCost: 42,
inventionDataFilename: '2026-07-12/lamp.inv',
}),
})
const { Invention } = (await save.json()) as InventionSaveResult
// The bare RRInventionVersion — the blob name is what the client downloads,
// BlobHash the base64 SHA-256 of what it will download.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
InventionId: Invention.InventionId,
VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv',
BlobHash: await base64Sha256(data),
InstantiationCost: 42,
})
// Only the current version exists; anything else 404s, as does an unknown id.
const v2 = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=2`
)
expect(v2.status).toBe(404)
const unknown = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=999999&version=1`
)
expect(unknown.status).toBe(404)
// Both params are required.
const noVersion = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}`
)
expect(noVersion.status).toBe(400)
const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/version?version=1`)
expect(noId.status).toBe(400)
})
test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => {
// Saved before the upload landed: nothing to hash, so the field stays null
// rather than carrying a hash of something the client can't download.
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }),
})
const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult
expect(InventionVersion.BlobHash).toBeNull()
const version = async (): Promise<Record<string, unknown>> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
return (await res.json()) as Record<string, unknown>
}
expect((await version()).BlobHash).toBeNull()
// Once the blob is there the hash resolves — here from the checksum recorded at
// upload time (what the storage worker puts), not by digesting the body.
const data = new Uint8Array([9, 8, 7])
await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, {
sha256: await crypto.subtle.digest('SHA-256', data),
})
const hash = await base64Sha256(data)
expect((await version()).BlobHash).toBe(hash)
// And it's kept, so the other invention endpoints serve it too — without the
// read counting as an edit (ModifiedAt is untouched).
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}`
)
const stored = (await details.json()) as SavedInvention
expect(stored.CurrentVersion.BlobHash).toBe(hash)
expect(stored.ModifiedAt).toBe(Invention.ModifiedAt)
})
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('3131')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Draft Lamp',
description: 'No description yet',
inventionDataFilename: 'a.inv',
}),
})
const { Invention } = (await save.json()) as InventionSaveResult
const update = async (query: string, sub = '3131'): Promise<Response> =>
exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&${query}`,
{ headers: await bearer(sub) }
)
// Update answers the save envelope. Only the params present change — the name
// is left alone here.
const res = await update(`description=${encodeURIComponent('my description')}`)
expect(res.status).toBe(200)
const edited = (await res.json()) as InventionSaveResult
expect(edited.Status).toBe(0)
expect(edited.InventionVersion.InventionId).toBe(Invention.InventionId)
expect(edited.Invention).toMatchObject({
InventionId: Invention.InventionId,
Description: 'my description',
Name: 'Draft Lamp',
IsPublished: false,
})
// `permission` takes a name or the raw number, and lands on GeneralPermission.
const byName = (await (await update('permission=edit_and_save')).json()) as InventionSaveResult
expect(byName.Invention.GeneralPermission).toBe(40)
const byNumber = (await (await update('permission=80')).json()) as InventionSaveResult
expect(byNumber.Invention.GeneralPermission).toBe(80)
// An empty description clears it; an empty name does *not* blank the invention.
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
// A supplied name/description is held to the same rules as the save path, and a
// rejected edit changes nothing.
expect((await update('name=xy')).status).toBe(400)
expect((await update(`name=${encodeURIComponent('Lamp?')}`)).status).toBe(400)
expect((await update(`description=${'x'.repeat(513)}`)).status).toBe(400)
const unchanged = (await (await update('permission=20')).json()) as InventionSaveResult
expect(unchanged.Invention).toMatchObject({ Name: 'Draft Lamp', Description: '' })
const renamed = (await (await update('name=Draft-Lamp%20Two')).json()) as InventionSaveResult
expect(renamed.Invention.Name).toBe('Draft-Lamp Two')
// allowTrial takes true/1.
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
expect(trial.Invention.AllowTrial).toBe(true)
// Update does not publish or price — those are v3/publish and v1/updateprice.
expect(trial.Invention.IsPublished).toBe(false)
// Only the creator may edit; unknown inventions 404; no token → 401.
expect((await update('description=nope', '9999')).status).toBe(403)
const unknown = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=999999&description=x`,
{ headers: await bearer('3131') }
)
expect(unknown.status).toBe(404)
const anon = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&description=x`
)
expect(anon.status).toBe(401)
})
test('GET /api/inventions/v3/publish publishes + prices; search then lists it', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('2121')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Publishable Lamp', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const search = async (): Promise<number[]> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent('Publishable Lamp')}`
)
return ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
}
// A saved draft is invisible until it's published.
expect(await search()).toEqual([])
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&permissionLevel=charge&price=250`,
{ headers: await bearer('2121') }
)
expect(res.status).toBe(200)
const published = (await res.json()) as InventionSaveResult
expect(published.Status).toBe(0)
expect(published.Invention).toMatchObject({
IsPublished: true,
GeneralPermission: 80, // charge
Price: 250,
})
expect(typeof published.Invention.FirstPublishedAt).toBe('string')
expect(await search()).toEqual([Invention.InventionId])
// Publishing with no permissionLevel defaults to UseOnly, and price to 0.
const other = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('2121')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Plain Lamp', inventionDataFilename: 'a.inv' }),
})
const plain = ((await other.json()) as InventionSaveResult).Invention
const defaulted = (await (
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${plain.InventionId}`,
{ headers: await bearer('2121') }
)
).json()) as InventionSaveResult
expect(defaulted.Invention).toMatchObject({
IsPublished: true,
GeneralPermission: 20, // useonly
Price: 0,
})
// Creator-gated like the other writes.
const notMine = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
{ headers: await bearer('9999') }
)
expect(notMine.status).toBe(403)
})
test('POST /api/inventions/v1/updateprice sets the price, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('1212')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Priced Lamp', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const updateprice = async (body: unknown, sub = '1212'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v1/updateprice`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const res = await updateprice({ InventionId: Invention.InventionId, Price: 500 })
expect(res.status).toBe(200)
const priced = (await res.json()) as InventionSaveResult
expect(priced.Status).toBe(0)
expect(priced.Invention.Price).toBe(500)
// A negative price is rejected; other players can't reprice someone's invention.
expect((await updateprice({ InventionId: Invention.InventionId, Price: -1 })).status).toBe(400)
expect(
(await updateprice({ InventionId: Invention.InventionId, Price: 10 }, '9999')).status
).toBe(403)
})
test('GET /api/inventions/v1/toptoday + v1/featured serve the invention feeds', async () => {
const ids = async (res: Response): Promise<number[]> =>
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
const beforeFeatured = await ids(
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
)
expect(beforeFeatured).toEqual(beforeTop)
const feedInvention = (
id: number,
downloads: number,
extra: Partial<SavedInvention> = {}
): SavedInvention =>
({
InventionId: id,
CreatorPlayerId: 8080,
Name: `Feed ${id}`,
Description: '',
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
IsFeatured: false,
HideFromPlayer: false,
NumDownloads: downloads,
CheerCount: 0,
NumPlayersHaveUsedInRoom: 0,
CreatedAt: '2026-07-01T00:00:00Z',
...extra,
}) as unknown as SavedInvention
for (const inv of [
feedInvention(201, 500),
feedInvention(202, 9000, { IsFeatured: true, CreatedAt: '2026-07-02T00:00:00Z' }),
feedInvention(203, 3000, { IsFeatured: true, CreatedAt: '2026-07-03T00:00:00Z' }),
// Unpublished/hidden inventions stay out of both feeds, featured or not.
feedInvention(204, 99999, { IsPublished: false, IsFeatured: true }),
feedInvention(205, 99999, { HideFromPlayer: true, IsFeatured: true }),
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
// Top: engagement-ranked, so the biggest download counts lead.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top.slice(0, 3)).toEqual([202, 203, 201])
expect(top).not.toContain(204)
expect(top).not.toContain(205)
// Featured: only the flagged, visible inventions — newest first.
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
expect(featured).toEqual([203, 202])
// skip/take paginate the top feed.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
expect(await ids(page)).toEqual([203])
})
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Value: 'hello world' }),
})
expect(san.status).toBe(200)
expect(await san.json()).toBe('hello world')
const pure = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, { method: 'POST' })
expect(pure.status).toBe(200)
expect(await pure.json()).toEqual({ IsPure: true })
})
})
describe('auth-gated endpoints', () => {
test('401 without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
expect(res.status).toBe(401)
})
test('401 with a garbage token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: { Authorization: 'Bearer not-a-real-token' },
})
expect(res.status).toBe(401)
})
})
describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/PlayerReporting/v3/create records the report', async () => {
const res = await submit(
{
PlayerIdReported: '205',
ReportCategory: '100',
Details: 'ya know',
HeightReporter: '1.64',
HeightReported: '1.65',
RoomId: '58',
RoomInstanceType: 'Public',
},
await bearer()
)
expect(res.status).toBe(200)
// `error` is an empty string, not null — the real service's envelope.
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getReportsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The reporter is the token's subject, not a body field.
reporter_player_id: 42,
reported_player_id: 205,
report_category: 100,
details: 'ya know',
height_reporter: 1.64,
height_reported: 1.65,
room_id: 58,
room_instance_type: 'Public',
})
expect(row?.created_at).toBeTruthy()
})
// Everything but the reported player is optional — a report raised outside a room
// carries no RoomId, and 0 means "no room" rather than room zero.
test('POST /api/PlayerReporting/v3/create stores absent fields as null', async () => {
const res = await submit({ PlayerIdReported: '206', RoomId: '0' }, await bearer())
expect(res.status).toBe(200)
const [row] = await getReportsAgainst(env.DB, 206)
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 206,
report_category: 0,
details: null,
height_reporter: null,
height_reported: null,
room_id: null,
room_instance_type: null,
})
})
// Append-only: a second report against the same player is a second row.
test('POST /api/PlayerReporting/v3/create appends rather than dedupes', async () => {
await submit({ PlayerIdReported: '207', Details: 'first' }, await bearer())
await submit({ PlayerIdReported: '207', Details: 'second' }, await bearer())
const rows = await getReportsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.details)).toEqual(['second', 'first'])
})
test('POST /api/PlayerReporting/v3/create 401s without a bearer token', async () => {
const res = await submit({ PlayerIdReported: '205' })
expect(res.status).toBe(401)
})
test('POST /api/PlayerReporting/v3/create 400s without a reported player', async () => {
const res = await submit({ Details: 'ya know' }, await bearer())
expect(res.status).toBe(400)
// Same envelope as the success branch — the client parses only one shape.
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
})
})
describe('player warnings', () => {
const MOD = ['gameClient', 'moderator']
const issue = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/playerwarnings`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/playerwarnings records the warning', async () => {
const res = await issue(
{
WarnedPlayerId: '205',
ReportCategory: '101',
DisplayReason: 'Sexual gestures',
ModeratorNote: 'dfg',
},
await bearer('42', MOD)
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getWarningsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The moderator is the token's subject, not a body field.
moderator_player_id: 42,
warned_player_id: 205,
report_category: 101,
display_reason: 'Sexual gestures',
moderator_note: 'dfg',
})
expect(row?.created_at).toBeTruthy()
})
test('POST /api/playerwarnings stores absent fields as null', async () => {
const res = await issue({ WarnedPlayerId: '206' }, await bearer('42', MOD))
expect(res.status).toBe(200)
const [row] = await getWarningsAgainst(env.DB, 206)
expect(row).toMatchObject({
warned_player_id: 206,
report_category: 0,
display_reason: null,
moderator_note: null,
})
})
// Append-only, like reports: warning the same player twice is two rows.
test('POST /api/playerwarnings appends rather than dedupes', async () => {
await issue({ WarnedPlayerId: '207', ModeratorNote: 'first' }, await bearer('42', MOD))
await issue({ WarnedPlayerId: '207', ModeratorNote: 'second' }, await bearer('42', MOD))
const rows = await getWarningsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.moderator_note)).toEqual(['second', 'first'])
})
test('POST /api/playerwarnings 401s without a bearer token', async () => {
const res = await issue({ WarnedPlayerId: '205' })
expect(res.status).toBe(401)
})
// A valid token is not enough — a plain player's carries neither staff role.
// Nothing is written on the rejected branch.
test('POST /api/playerwarnings 403s without a staff role', async () => {
for (const roles of [undefined, ['gameClient']]) {
const res = await issue({ WarnedPlayerId: '208' }, await bearer('42', roles))
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await getWarningsAgainst(env.DB, 208)).toHaveLength(0)
})
// `developer` gets in as well as `moderator` — staff hold both.
test('POST /api/playerwarnings accepts the developer role', async () => {
const res = await issue(
{ WarnedPlayerId: '209' },
await bearer('42', ['gameClient', 'developer'])
)
expect(res.status).toBe(200)
expect(await getWarningsAgainst(env.DB, 209)).toHaveLength(1)
})
test('POST /api/playerwarnings 400s without a warned player', async () => {
const res = await issue({ ModeratorNote: 'dfg' }, await bearer('42', MOD))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'WarnedPlayerId is required' })
})
})
describe('rooms', () => {
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/verifyRole`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(sub ? await bearer(sub) : {}),
},
body: new URLSearchParams(fields).toString(),
})
expect(res.status).toBe(200)
return (await res.json()) as boolean
}
// No token → false.
expect(await verify({ roomId: '2', role: '255' })).toBe(false)
// Creator (account 1 owns room 2) → true regardless of role.
expect(await verify({ roomId: '2', role: '255', context: 'MakerPen' }, '1')).toBe(true)
// Non-creator with no role in the room → false.
expect(await verify({ roomId: '2', role: '30' }, '42')).toBe(false)
// Account 42 holds Role 30 in room 3 → passes when requesting ≤ 30…
expect(await verify({ roomId: '3', role: '30' }, '42')).toBe(true)
// …but not a higher role.
expect(await verify({ roomId: '3', role: '255' }, '42')).toBe(false)
// Unknown room → false.
expect(await verify({ roomId: '99999', role: '0' }, '42')).toBe(false)
})
})
describe('images', () => {
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer(),
body: fd,
})
expect(res.status).toBe(200)
const { ImageName } = (await res.json()) as { ImageName: string }
// Keyed by <type>/<date>/<uuid>.<ext> (the type folder mirrors the CDN layout).
expect(ImageName).toMatch(
/^sharecamera\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/
)
// The object is in the shared bucket under that key.
const stored = await env.IMAGES.get(ImageName)
expect(stored).not.toBeNull()
expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes)
// A metadata row was created, and it's readable by name via /api/images/v6.
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number }
expect(meta.ImageName).toBe(ImageName)
expect(meta.PlayerId).toBe(42)
expect(typeof meta.Id).toBe('number')
expect(meta.CheerCount).toBe(0)
})
test('GET /api/images/v1/slideshow is public and joins username + room name', async () => {
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
.bind(
JSON.stringify({
Id: 9001,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName: 'slide9001.jpg',
Description: null,
PlayerId: 42,
TaggedPlayerIds: [7, 8],
RoomId: 2,
PlayerEventId: null,
CreatedAt: new Date().toISOString(),
CheerCount: 0,
CommentCount: 0,
})
)
.run()
// No token — the slideshow is public.
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
Images: Array<Record<string, unknown>>
ValidTill: string
}
expect(body.ValidTill).toMatch(/Z$/)
const slide = body.Images.find((i) => i.SavedImageId === 9001)
expect(slide).toMatchObject({
SavedImageId: 9001,
ImageName: 'slide9001.jpg',
Username: 'Tester', // account 42 seeded above
RoomName: 'RecCenter', // room 2
RoomId: 2,
SavedImageType: 1,
Accessibility: 1,
PlayerIds: [7, 8],
})
})
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
// Seed an image to cheer.
// Its own player id: 700's photos are asserted on exactly in the player-list test.
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 7001 })
const cheerBody = JSON.stringify({ SavedImageId: img.Id, Cheer: true })
// No token → 401.
expect(
(
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: cheerBody,
})
).status
).toBe(401)
const cheer = async (cheerVal: boolean, sub = '42') =>
exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ SavedImageId: img.Id, Cheer: cheerVal }),
})
const cheerCount = async (): Promise<number> => {
const row = await env.DB.prepare('SELECT data FROM image WHERE id = ?1')
.bind(img.Id)
.first<{ data: string }>()
return (JSON.parse(row!.data) as { CheerCount: number }).CheerCount
}
// Account 42 cheers → CheerCount syncs to 1 (a real integer, not 1.0).
expect((await cheer(true)).status).toBe(200)
const rawAfter = await env.DB.prepare('SELECT data FROM image WHERE id = ?1')
.bind(img.Id)
.first<{ data: string }>()
expect(rawAfter!.data).toContain('"CheerCount":1')
expect(rawAfter!.data).not.toContain('"CheerCount":1.0')
expect(await cheerCount()).toBe(1)
// Re-cheering is idempotent on the count.
await cheer(true)
expect(await cheerCount()).toBe(1)
// Un-cheer → count back to 0.
await cheer(false)
expect(await cheerCount()).toBe(0)
})
test('GET /api/images/v5/cheered/bulk reports per-id cheer state for the caller (auth-gated)', async () => {
const img = await createImage(env.DB, { imageName: 'bulkcheer.jpg', playerId: 701 })
const other = 999999
// No token → 401.
expect(
(await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}`)).status
).toBe(401)
const bulk = async (sub: string) =>
(await (
await exports.default.fetch(
`${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}&id=${other}`,
{ headers: await bearer(sub) }
)
).json()) as Array<{ SavedImageId: number; IsCheered: boolean }>
// Before cheering: one entry per requested id, in order, all false.
expect(await bulk('42')).toEqual([
{ SavedImageId: img.Id, IsCheered: false },
{ SavedImageId: other, IsCheered: false },
])
// Account 42 cheers the image.
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ SavedImageId: img.Id, Cheer: true }),
})
// The cheerer sees it cheered; a different player does not.
expect((await bulk('42')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(true)
expect((await bulk('43')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(false)
// No ids → empty array.
const empty = await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk`, {
headers: await bearer('42'),
})
expect(await empty.json()).toEqual([])
})
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400)
expect(
(await exports.default.fetch(`${ORIGIN}/api/images/v6?name=doesnotexist.jpg`)).status
).toBe(404)
})
test('POST /api/images/v4/uploadsaved records metadata from imgMeta', async () => {
const fd = new FormData()
// The client's real imgMeta shape (tagged players are `playerIds`).
fd.append(
'imgMeta',
JSON.stringify({
playerIds: [5, 6],
savedImageType: 1,
roomId: 777,
playerEventId: 0,
accessibility: 2,
})
)
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer('42'),
body: fd,
})
const { ImageName } = (await res.json()) as { ImageName: string }
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as {
Type: number
RoomId: number
Accessibility: number
TaggedPlayerIds: number[]
PlayerEventId: number | null
}
expect(meta.Type).toBe(1)
expect(meta.RoomId).toBe(777)
expect(meta.Accessibility).toBe(2)
expect(meta.TaggedPlayerIds).toEqual([5, 6])
// playerEventId 0 means "none" → stored as null.
expect(meta.PlayerEventId).toBeNull()
})
test('POST /api/images/v4/uploadsaved records a profile thumbnail on the account', async () => {
const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3])
const fd = new FormData()
// Type 4 = ProfileThumbnail. The client sends the file as image.dat.
fd.append('imgMeta', JSON.stringify({ savedImageType: 4, roomId: -1 }))
fd.append('image', new File([bytes], 'image.dat', { type: 'image/jpeg' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer('42'),
body: fd,
})
expect(res.status).toBe(200)
const { ImageName } = (await res.json()) as { ImageName: string }
// Type 4 → the `profile/` type folder, then <date>/<uuid>.<ext>.
expect(ImageName).toMatch(
/^profile\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/
)
// The account row now points its profileImage at the uploaded key.
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = 42').first<{
data: string
}>()
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
})
test('DELETE /api/images/v1/deletesaved removes the owners image (row + cheers + R2)', async () => {
const ImageName = 'sharecamera/2026-07-17/delete-me.jpg'
await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3]))
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
.bind(
JSON.stringify({
Id: 8100,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName,
Description: null,
PlayerId: 42, // owned by the default bearer account
TaggedPlayerIds: [],
RoomId: null,
PlayerEventId: null,
CreatedAt: new Date().toISOString(),
CheerCount: 1,
CommentCount: 0,
})
)
.run()
await env.DB.prepare(
'INSERT INTO image_interaction (player_id, saved_image_id, cheered) VALUES (99, 8100, 1)'
).run()
const del = (headers: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ ImageName }),
})
// No token → 401; a different account → 403 (still present afterwards).
expect((await del({})).status).toBe(401)
expect((await del(await bearer('43'))).status).toBe(403)
expect(await getImageByName(env.DB, ImageName)).not.toBeNull()
// Unknown image → 404.
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
})
expect(unknown.status).toBe(404)
// Owner → 200, and the row, its cheers, and the R2 object are all gone.
expect((await del(await bearer('42'))).status).toBe(200)
expect(await getImageByName(env.DB, ImageName)).toBeNull()
expect(await env.IMAGES.get(ImageName)).toBeNull()
const cheers = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = 8100'
).first<{ n: number }>()
expect(cheers!.n).toBe(0)
})
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
const fd = new FormData()
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
body: fd,
})
expect(res.status).toBe(401)
})
test('POST /api/images/v4/uploadsaved 400s without a file', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'foo=bar',
})
expect(res.status).toBe(400)
})
test('GET /api/images/v4/room/:id returns a public room feed, filtered/sorted/paginated', async () => {
// Seed images in room 54: two public (one with more cheers, of different
// types), one private (hidden), and one in another room (excluded).
const seed = (img: Partial<SavedImage> & { Id: number }) =>
env.DB.prepare('INSERT INTO image (data) VALUES (?1)').bind(
JSON.stringify({
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName: `img${img.Id}.jpg`,
Description: null,
PlayerId: 42,
TaggedPlayerIds: [],
RoomId: 54,
PlayerEventId: null,
CreatedAt: '2026-01-01T00:00:00.000Z',
CheerCount: 0,
CommentCount: 0,
...img,
})
)
await env.DB.batch([
seed({ Id: 101, CheerCount: 5, CreatedAt: '2026-02-01T00:00:00.000Z' }),
seed({ Id: 102, CheerCount: 9, CreatedAt: '2026-01-15T00:00:00.000Z', Type: 3 }),
seed({ Id: 103, Accessibility: 0 }), // private → hidden from the public feed
seed({ Id: 104, RoomId: 99 }), // different room → excluded
])
// sort=1 → most cheered first (102 has 9, 101 has 5).
const top = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/room/54?sort=1&filter=0&take=100&skip=0`)
).json()) as SavedImage[]
expect(top.map((i) => i.Id)).toEqual([102, 101])
// sort=0 → newest first (101 is more recent than 102).
const newest = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/room/54?sort=0`)
).json()) as SavedImage[]
expect(newest.map((i) => i.Id)).toEqual([101, 102])
// filter=1 (ShareCamera) drops the Type-3 image (102).
const filtered = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/room/54?filter=1`)
).json()) as SavedImage[]
expect(filtered.map((i) => i.Id)).toEqual([101])
// take/skip paginate.
const page = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/room/54?sort=1&take=1&skip=1`)
).json()) as SavedImage[]
expect(page.map((i) => i.Id)).toEqual([101])
// A room with no images → empty array.
expect(
await (await exports.default.fetch(`${ORIGIN}/api/images/v4/room/12345`)).json()
).toEqual([])
})
test('GET /api/images/v4/player/:id and v3/feed/player/:id return the player photos + feed', async () => {
const seed = (img: Partial<SavedImage> & { Id: number }) =>
env.DB.prepare('INSERT INTO image (data) VALUES (?1)').bind(
JSON.stringify({
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName: `p${img.Id}.jpg`,
Description: null,
PlayerId: 700,
TaggedPlayerIds: [],
RoomId: null,
PlayerEventId: null,
CreatedAt: '2026-01-01T00:00:00.000Z',
CheerCount: 0,
CommentCount: 0,
...img,
})
)
await env.DB.batch([
// Player 700's own photos (newest last so ordering is exercised).
seed({ Id: 201, PlayerId: 700, CreatedAt: '2026-03-01T00:00:00.000Z' }),
seed({ Id: 202, PlayerId: 700, CreatedAt: '2026-04-01T00:00:00.000Z' }),
seed({ Id: 203, PlayerId: 700, Accessibility: 0 }), // private → hidden
// Taken by someone else, but player 700 is tagged in it → feed only.
seed({
Id: 204,
PlayerId: 999,
TaggedPlayerIds: [700],
CreatedAt: '2026-05-01T00:00:00.000Z',
}),
// Unrelated to 700 → in neither.
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
])
// The lists serve the client's ImagesPlayer projection: the id and type are
// SavedImageId/SavedImageType, and TaggedPlayerIds isn't part of it.
type ImagesPlayer = { SavedImageId: number; SavedImageType: number; ImageName: string }
// v4/player → only photos 700 *took*, public, newest first.
const mine = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`)
).json()) as ImagesPlayer[]
expect(mine.map((i) => i.SavedImageId)).toEqual([202, 201])
expect(mine[0]).toEqual({
Accessibility: 1,
AccessibilityLocked: false,
CheerCount: 0,
CommentCount: 0,
CreatedAt: '2026-04-01T00:00:00.000Z',
Description: null,
ImageName: 'p202.jpg',
PlayerEventId: null,
PlayerId: 700,
RoomId: null,
SavedImageId: 202,
SavedImageType: 1,
})
// take paginates.
const one = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700?take=1`)
).json()) as ImagesPlayer[]
expect(one.map((i) => i.SavedImageId)).toEqual([202])
// v5/player is the same list with a sort option (0 = newest first).
const sorted = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v5/player/700?sort=0`)
).json()) as ImagesPlayer[]
expect(sorted.map((i) => i.SavedImageId)).toEqual([202, 201])
// v3/feed/player → photos taken *or* tagged in, newest first (204 is newest).
const feed = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700?take=100`)
).json()) as ImagesPlayer[]
expect(feed.map((i) => i.SavedImageId)).toEqual([204, 202, 201])
// A player with no photos → empty array on both.
expect(
await (await exports.default.fetch(`${ORIGIN}/api/images/v4/player/424242`)).json()
).toEqual([])
expect(
await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json()
).toEqual([])
})
})
describe('relationships', () => {
// RelationshipType: 0 None, 1 FriendRequestSent, 2 FriendRequestReceived, 3 Friend.
type Rel = { PlayerID: number; RelationshipType: number; Favorited: number }
// Call a relationship mutation as `sub`, targeting `playerId` — the real client
// shape: a GET with the target in `?id=`.
async function mutate(path: string, sub: string, playerId: number) {
return exports.default.fetch(`${ORIGIN}${path}?id=${playerId}`, {
headers: await bearer(sub),
})
}
// Fetch `sub`'s relationships, projected from their point of view.
async function relationships(sub: string): Promise<Rel[]> {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
headers: await bearer(sub),
})
return (await res.json()) as Rel[]
}
// Standard ack the flag endpoints (favorite/ignore/mute + inverses) now return —
// the relationship detail rides a RelationshipChanged hub notification instead.
const ACK = { Success: true, Message: '' }
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Notification = {
playerId: number
notificationType: number
data: { PlayerID: number; RelationshipType: number; Favorited: number; Ignored: number }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
/** Drop everything the hub stub has recorded so far. */
async function resetNotifications() {
await hub().fetch('http://do/all', { method: 'DELETE' })
}
/** Every notification pushed since the last reset, in order. */
async function sentNotifications(): Promise<Notification[]> {
return (await (await hub().fetch('http://do/all')).json()) as Notification[]
}
// POST a flag mutation the real client way (form body `PlayerId=<id>`), returning
// the parsed ack body.
async function ackFlag(path: string, sub: string, playerId: number) {
return (await (
await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: `PlayerId=${playerId}`,
})
).json()) as { Success: boolean; Message: string }
}
// A player's own-side flags read straight from the relationship row — the flag
// endpoints return only an ack, so the effect is verified against the row itself.
async function ownFlags(playerId: number, otherId: number) {
const row = (await env.DB.prepare(
`SELECT requester_id, requester_favorited, requester_ignored, requester_muted,
target_favorited, target_ignored, target_muted
FROM relationship
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
)
.bind(playerId, otherId)
.first()) as Record<string, number> | null
if (!row) return null
const isRequester = row.requester_id === playerId
return {
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
Muted: isRequester ? row.requester_muted : row.target_muted,
}
}
test('GET /api/relationships/v2/get is auth-gated', async () => {
expect((await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)).status).toBe(401)
})
test('mutations are auth-gated', async () => {
for (const path of [
'/api/relationships/v2/sendfriendrequest',
'/api/relationships/v2/acceptfriendrequest',
'/api/relationships/v2/removefriend',
'/api/relationships/v2/addfriend',
'/api/relationships/v1/ignore',
'/api/relationships/v1/mute',
'/api/relationships/v1/favorite',
'/api/relationships/v1/unfavorite',
]) {
const res = await exports.default.fetch(`${ORIGIN}${path}?id=1`)
expect(res.status).toBe(401)
}
})
test('send → the two sides see Sent / Received; accept → both Friend; remove → gone', async () => {
// 500 sends 501 a request.
const sent = (await (
await mutate('/api/relationships/v2/sendfriendrequest', '500', 501)
).json()) as Rel
expect(sent).toMatchObject({ PlayerID: 501, RelationshipType: 1 })
// 500 sees it as Sent (1); 501 sees the mirror as Received (2).
expect(await relationships('500')).toEqual([
{ PlayerID: 501, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 },
])
expect(await relationships('501')).toEqual([
{ PlayerID: 500, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
])
// 501 accepts → both are Friends (3).
const accepted = (await (
await mutate('/api/relationships/v2/acceptfriendrequest', '501', 500)
).json()) as Rel
expect(accepted).toMatchObject({ PlayerID: 500, RelationshipType: 3 })
expect(await relationships('500')).toEqual([
{ PlayerID: 501, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
])
expect(await relationships('501')).toEqual([
{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
])
// 500 removes → both sides drop to None. The row is kept (that's where the
// per-side flags live), so v2/get still reports the pair, now as None (0).
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
expect(await relationships('500')).toEqual([
{ PlayerID: 501, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
])
expect(await relationships('501')).toEqual([
{ PlayerID: 500, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
])
})
test('removefriend keeps the callers ignore flag', async () => {
// 760 befriends 761 then ignores them; dropping the friendship must not
// un-ignore them (the flag lives on the row the removal downgrades to None).
await mutate('/api/relationships/v2/addfriend', '760', 761)
await ackFlag('/api/relationships/v1/ignore', '760', 761)
await mutate('/api/relationships/v2/removefriend', '760', 761)
expect(await ownFlags(760, 761)).toMatchObject({ Ignored: 1 })
expect(await relationships('760')).toEqual([
{ PlayerID: 761, RelationshipType: 0, Favorited: 0, Ignored: 1, Muted: 0 },
])
})
test('addfriend makes them friends directly', async () => {
const res = (await (await mutate('/api/relationships/v2/addfriend', '510', 511)).json()) as Rel
expect(res).toMatchObject({ PlayerID: 511, RelationshipType: 3 })
expect(await relationships('511')).toEqual([
{ PlayerID: 510, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
])
})
test('crossing friend requests become a friendship', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '520', 521)
// 521 sends back to 520 → the crossing requests resolve to Friend for both.
const crossed = (await (
await mutate('/api/relationships/v2/sendfriendrequest', '521', 520)
).json()) as Rel
expect(crossed).toMatchObject({ PlayerID: 520, RelationshipType: 3 })
expect(await relationships('520')).toEqual([
{ PlayerID: 521, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
])
})
test('a self-targeted request is rejected', async () => {
expect((await mutate('/api/relationships/v2/sendfriendrequest', '530', 530)).status).toBe(400)
})
test('v1 ignore/mute set the callers own side of the relationship', async () => {
type FullRel = { PlayerID: number; RelationshipType: number; Ignored: number; Muted: number }
// 700 ignores 701 with no prior relationship → a bare None row, the caller's side
// flagged. The response is now just the ack; the flag is verified on the row.
expect(await ackFlag('/api/relationships/v1/ignore', '700', 701)).toEqual(ACK)
expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 0 })
// 700 then mutes 701 → same row, mute added, the earlier ignore preserved.
expect(await ackFlag('/api/relationships/v1/mute', '700', 701)).toEqual(ACK)
expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 1 })
// The tricky case: the caller is the row's TARGET. 710 sends 711 a request
// (710 = requester); 711 ignoring 710 must flag the target side, not the requester's.
await mutate('/api/relationships/v2/sendfriendrequest', '710', 711)
expect(await ackFlag('/api/relationships/v1/ignore', '711', 710)).toEqual(ACK)
// 711 sees 710's request as Received (2) with their own Ignored set.
expect((await relationships('711')) as unknown as FullRel[]).toEqual([
expect.objectContaining({ PlayerID: 710, RelationshipType: 2, Ignored: 1 }),
])
// 710's own side is untouched — the requester never ignored anyone.
expect((await relationships('710')) as unknown as FullRel[]).toEqual([
expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 }),
])
})
test('v1 unignore/unmute clear the callers own flags independently', async () => {
// 800 ignores and mutes 801 (bare None row, both flags on the caller's side).
await ackFlag('/api/relationships/v1/ignore', '800', 801)
await ackFlag('/api/relationships/v1/mute', '800', 801)
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 1, Muted: 1 })
// unignore clears only Ignored; the mute is left in place.
expect(await ackFlag('/api/relationships/v1/unignore', '800', 801)).toEqual(ACK)
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 1 })
// unmute then clears Muted too.
expect(await ackFlag('/api/relationships/v1/unmute', '800', 801)).toEqual(ACK)
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 0 })
})
test('v1 favorite/unfavorite toggle the callers own side, leaving the friendship intact', async () => {
// 720 and 721 are friends; 720 favorites 721 — the real client shape, a GET with `?id=`.
await mutate('/api/relationships/v2/addfriend', '720', 721)
expect(await (await mutate('/api/relationships/v1/favorite', '720', 721)).json()).toEqual(ACK)
// 720's own side is favorited; the friendship is intact.
expect(await relationships('720')).toEqual([
{ PlayerID: 721, RelationshipType: 3, Favorited: 1, Ignored: 0, Muted: 0 },
])
// Favoriting is one-sided: 721 does not see themselves as having favorited 720.
expect(await relationships('721')).toEqual([
{ PlayerID: 720, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
])
// Unfavorite clears the flag but keeps the friendship.
expect(await (await mutate('/api/relationships/v1/unfavorite', '720', 721)).json()).toEqual(ACK)
expect(await relationships('720')).toEqual([
{ PlayerID: 721, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
])
})
test('favoriting a player you have no relationship with is allowed', async () => {
// Mirrors ignore/mute: a bare None row is created with the caller's side flagged.
expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK)
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
// The bare None row is reported by v2/get — it carries the flag.
expect(await relationships('730')).toEqual([
{ PlayerID: 731, RelationshipType: 0, Favorited: 1, Ignored: 0, Muted: 0 },
])
})
test('a self-targeted favorite is rejected', async () => {
expect((await mutate('/api/relationships/v1/favorite', '740', 740)).status).toBe(400)
})
test('a flag change pushes a RelationshipChanged notification with the relationship', async () => {
// The relationship detail now rides a hub notification instead of the response.
// The notify DO is stubbed to record its last notifyPlayer call (see vitest.config).
await ackFlag('/api/relationships/v1/favorite', '750', 751)
const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last')
const last = (await res.json()) as {
playerId: number
notificationType: number
data: { PlayerID: number; Favorited: number; RelationshipType: number }
}
expect(last.playerId).toBe(750) // sent to the caller
expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged
expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 })
})
test('sendfriendrequest notifies both players with their own projection', async () => {
await resetNotifications()
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
// Both sides hear about it, each seeing the other player and their own side's
// type: the sender Sent (1), the recipient Received (2).
expect(await sentNotifications()).toEqual([
{
playerId: 770,
notificationType: 1,
data: { PlayerID: 771, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 },
},
{
playerId: 771,
notificationType: 1,
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
},
])
})
test('accepting notifies both players as Friend', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '780', 781)
await resetNotifications()
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
expect(sent).toEqual(
expect.arrayContaining([
expect.objectContaining({
playerId: 780,
data: expect.objectContaining({ PlayerID: 781, RelationshipType: 3 }),
}),
expect.objectContaining({
playerId: 781,
data: expect.objectContaining({ PlayerID: 780, RelationshipType: 3 }),
}),
])
)
})
test('removefriend notifies both players with None', async () => {
await mutate('/api/relationships/v2/addfriend', '790', 791)
await resetNotifications()
await mutate('/api/relationships/v2/removefriend', '790', 791)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([790, 791])
for (const n of sent) expect(n.data.RelationshipType).toBe(0)
})
test('a no-op friend request notifies nobody', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
await resetNotifications()
// Re-sending an already-outstanding request writes nothing, so nothing is pushed.
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
expect(await sentNotifications()).toEqual([])
// Likewise accepting something that isn't pending (810 has no request to accept).
await mutate('/api/relationships/v2/acceptfriendrequest', '810', 811)
expect(await sentNotifications()).toEqual([])
})
test('crossing requests notify both players as Friend', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '820', 821)
await resetNotifications()
// 821's request crosses 820's → an immediate friendship, both sides told.
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
})
})
describe('messages', () => {
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Sent = {
playerId: number
notificationType: number
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const pushed = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const send = async (fields: Record<string, string>, headers?: Record<string, string>) => {
await hub().fetch('http://do/all', { method: 'DELETE' })
return exports.default.fetch(`${ORIGIN}/api/messages/v2/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
}
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
const MESSAGE_RECEIVED = 2
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await pushed()).toEqual([
{
// Delivered to the recipient, not the sender.
playerId: 2,
notificationType: MESSAGE_RECEIVED,
// FromPlayerId is the token's subject, not a body field.
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' },
},
])
})
test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
const res = await send({ ToPlayerId: '2' }, await bearer('42'))
expect(res.status).toBe(200)
expect((await pushed())[0]?.data).toEqual({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 0,
Data: '',
})
})
test('POST /api/messages/v2/send 400s without a recipient, pushing nothing', async () => {
const res = await send({ Type: '10' }, await bearer('42'))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'ToPlayerId is required' })
expect(await pushed()).toEqual([])
})
test('POST /api/messages/v2/send is auth-gated', async () => {
const res = await send({ ToPlayerId: '2' })
expect(res.status).toBe(401)
expect(await pushed()).toEqual([])
})
})
describe('mutual friends', () => {
// High, distinct ids so the friendships seeded here don't collide with the
// relationship tests above.
const CALLER = 800
const OTHER = 801
type Card = { AccountId: number; Username: string; DisplayName: string; ProfileImage: string }
const mutuals = async (query: string, sub = String(CALLER)): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends${query}`, {
headers: await bearer(sub),
})
beforeAll(async () => {
const rel = (a: number, b: number, type = 3) =>
env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
).bind(a, b, type)
// 804 has no profileImage key at all — the projection must still answer a
// string. 806 is deliberately given no account row.
const account = (id: number, extra: Record<string, unknown>) =>
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)').bind(
JSON.stringify({ accountId: id, username: `P${id}`, displayName: `Player ${id}`, ...extra })
)
await env.DB.batch([
account(CALLER, { profileImage: 'p800.jpg' }),
account(OTHER, { profileImage: 'p801.jpg' }),
account(802, { profileImage: 'p802.jpg' }),
account(803, { profileImage: 'p803.jpg' }),
account(804, {}),
// Seeded 804-first so the ascending order of the answer is the code's doing,
// not the insertion order's.
rel(CALLER, 804),
rel(802, CALLER), // friendship recorded from the other direction
rel(CALLER, 803),
rel(CALLER, 806),
rel(OTHER, 804), // shared → in the answer
rel(OTHER, 802), // shared → in the answer
rel(803, OTHER, 1), // only a pending request → NOT a friend of OTHER
rel(OTHER, 806), // shared, but 806 has no account row → dropped
])
})
test('GET /api/relationships/mutualfriends returns the shared friends', async () => {
const res = await mutuals(`?id=${OTHER}`)
expect(res.status).toBe(200)
const cards = (await res.json()) as Card[]
// 803 is only a pending request on OTHER's side, and 806 has no account row.
expect(cards.map((p) => p.AccountId)).toEqual([802, 804])
expect(cards[0]).toEqual({
AccountId: 802,
Username: 'P802',
DisplayName: 'Player 802',
ProfileImage: 'p802.jpg',
})
// No stored image → an empty string, never null/undefined.
expect(cards[1]?.ProfileImage).toBe('')
})
// The degenerate cases answer an empty list rather than an error — this feeds a
// profile panel, which would otherwise have nothing to render.
// `?id=` is the only accepted form — `?playerId=` reads as no id at all.
test('GET /api/relationships/mutualfriends answers [] for a missing/self/bad id', async () => {
for (const query of ['', '?id=0', '?id=-5', '?id=abc', `?id=${CALLER}`, `?playerId=${OTHER}`]) {
const res = await mutuals(query)
expect(res.status, query).toBe(200)
expect(await res.json(), query).toEqual([])
}
})
// Symmetric: 802 and 803 aren't friends with each other, but both are friends with
// 800, so 800 is what they have in common.
test('GET /api/relationships/mutualfriends works between two other players', async () => {
const cards = (await (await mutuals('?id=803', '802')).json()) as Card[]
expect(cards.map((p) => p.AccountId)).toEqual([CALLER])
})
test('GET /api/relationships/mutualfriends answers [] with nothing in common', async () => {
// 809 has no relationships at all.
const cards = (await (await mutuals('?id=809', '802')).json()) as Card[]
expect(cards).toEqual([])
})
test('GET /api/relationships/mutualfriends is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends?id=${OTHER}`)
expect(res.status).toBe(401)
})
})
describe('player events', () => {
const HOUR = 60 * 60 * 1000
/** Seconds precision, no milliseconds — the form the client sends and reads back. */
const at = (offsetMs: number): string =>
new Date(Date.now() + offsetMs).toISOString().replace(/\.\d{3}Z$/, 'Z')
const post = async (path: string, body: unknown, sub = '42'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
body: JSON.stringify(body),
})
const create = async (body: unknown, sub = '42'): Promise<PlayerEvent> => {
const res = await post('/api/playerevents/v2', body, sub)
expect(res.status).toBe(200)
return ((await res.json()) as PlayerEventResult).PlayerEvent
}
const get = async (path: string, sub?: string): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, sub ? { headers: await bearer(sub) } : undefined)
// The fixture set every test below reads. Times are relative to the run so the
// upcoming/live/finished distinction the browse queries make is real.
let upcoming: PlayerEvent
let clubEvent: PlayerEvent
let liveEvent: PlayerEvent
let pastEvent: PlayerEvent
beforeAll(async () => {
// Posted nested under `PlayerEvent` — the envelope form the client sends back.
upcoming = await create({
PlayerEvent: {
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
RoomId: 10916706,
SubRoomId: 11195660,
ClubId: null,
Name: 'Building a Better Room Using Trigonometry',
Description: '',
StartTime: at(HOUR),
EndTime: at(2 * HOUR),
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: true,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
},
})
// …and this one at the top level, the other form in circulation.
clubEvent = await create({
RoomId: 23570830,
ClubId: 7,
Name: 'DUNGEONS Escape ROOM',
Description: 'Try and escape the DUNGEONS with upto 4 players!',
StartTime: at(3 * HOUR),
EndTime: at(4 * HOUR),
CanRequestBroadcastPermissions: 2147483647,
})
liveEvent = await create(
{ RoomId: 3, ClubId: 7, Name: 'Live Jam', StartTime: at(-HOUR), EndTime: at(HOUR) },
'43'
)
pastEvent = await create({
RoomId: 3,
Name: 'Trigonometry Retrospective',
StartTime: at(-3 * HOUR),
EndTime: at(-2 * HOUR),
})
})
test('GET /api/playerevents/v1/tagfilters serves the event categories, auth-gated', async () => {
expect((await get('/api/playerevents/v1/tagfilters')).status).toBe(401)
const res = await get('/api/playerevents/v1/tagfilters', '42')
expect(res.status).toBe(200)
// Static — the categories the client offers, not derived from stored events.
// Trending is null even in the reference: it needs recent-activity data.
expect(await res.json()).toEqual({
PinnedFilters: [
'workshops',
'celebration',
'game',
'meetup',
'performance',
'coop',
'grandopening',
'class',
'competition',
],
PopularFilters: [
'workshops',
'celebration',
'class',
'coop',
'competition',
'game',
'grandopening',
'meetup',
'performance',
],
TrendingFilters: null,
})
})
test('POST /api/playerevents/v2 creates an event, auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2`, {
method: 'POST',
body: '{}',
})
expect(res.status).toBe(401)
// The stored record carries exactly the client's field set — nothing more.
expect(upcoming).toEqual({
PlayerEventId: upcoming.PlayerEventId,
CreatorPlayerId: 42,
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
RoomId: 10916706,
SubRoomId: 11195660,
ClubId: null,
Name: 'Building a Better Room Using Trigonometry',
Description: '',
StartTime: at(HOUR),
EndTime: at(2 * HOUR),
AttendeeCount: 1,
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: true,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
})
// Timestamps come back at seconds precision, as the client sends them.
expect(upcoming.StartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
})
// The one thing the event writes are strict about. Everything else here defaults a
// missing or unusable field (a nameless event becomes "Untitled Event"), but a name or
// description past the stored length can't be defaulted into anything sensible, and
// truncating a player's description silently is worse than refusing the write.
//
// Deliberately length ONLY: an event name is a title, not an identifier — the fixture
// above is called "Building a Better Room Using Trigonometry" — so the alphanumeric
// rule that guards usernames and room names would be wrong here.
test('POST /api/playerevents/v2 caps the name at 64 and the description at 512', async () => {
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(65), RoomId: 3 })).status).toBe(
400
)
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(64), RoomId: 3 })).status).toBe(
200
)
const withDescription = (description: string) =>
post('/api/playerevents/v2', { Name: 'Described', RoomId: 3, Description: description })
expect((await withDescription('d'.repeat(513))).status).toBe(400)
expect((await withDescription('d'.repeat(512))).status).toBe(200)
// Counted in code points, so an emoji costs one character rather than two.
expect((await withDescription('🎉'.repeat(512))).status).toBe(200)
// Spaces and punctuation stay fine — this is a title, not an identifier.
expect(
(await post('/api/playerevents/v2', { Name: "Bob's Big Night (2)!", RoomId: 3 })).status
).toBe(200)
// The update path enforces the same limits, and a refusal leaves the event alone.
const event = await create({ Name: 'EditMe', RoomId: 3 })
const tooLong = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
Name: 'n'.repeat(65),
})
expect(tooLong.status).toBe(400)
const after = await get(`/api/playerevents/v1/${event.PlayerEventId}`)
expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe')
})
test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => {
const res = await post('/api/playerevents/v2', { Name: 'Enveloped', RoomId: 3 })
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Always null: no event tags are stored, but the field has to be present.
expect(body.TagModifyResult).toBeNull()
expect(body.PlayerEvent.Name).toBe('Enveloped')
})
test('POST /api/playerevents/v2 pushes a PlayerEventCreated notification to the creator', async () => {
// The notify DO is stubbed to record its last notifyPlayer call (see vitest.config).
const event = await create({
RoomId: 58,
Name: 'Open Mic',
Description: 'come hang',
StartTime: at(HOUR),
EndTime: at(3 * HOUR),
})
const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last')
const last = (await res.json()) as {
playerId: number
notificationType: number
data: Record<string, unknown>
}
expect(last.playerId).toBe(42) // the creator
expect(last.notificationType).toBe(80) // NotificationType.PlayerEventCreated
// camelCase, unlike the PascalCase record the response carries; `tags` and
// `broadcastingRoomInstanceId` don't exist on the record, and `State` is dropped.
// The real hub strips the null values from the frame before it goes on the wire.
expect(last.data).toEqual({
tags: [],
playerEventId: event.PlayerEventId,
creatorPlayerId: 42,
roomId: 58,
subRoomId: null,
clubId: null,
name: 'Open Mic',
description: 'come hang',
imageName: '', // empty string, not the record's null
startTime: `${event.StartTime.slice(0, -1)}.0000000Z`,
endTime: `${event.EndTime.slice(0, -1)}.0000000Z`,
attendeeCount: 1,
accessibility: 1,
isMultiInstance: false,
supportMultiInstanceRoomChat: false,
defaultBroadcastPermissions: 0,
canRequestBroadcastPermissions: 0,
broadcastingRoomInstanceId: null,
})
// Tick precision on the frame; the stored record keeps its bare form.
expect(event.StartTime).toMatch(/:\d{2}Z$/)
})
test('POST /api/playerevents/v2 takes the creator from the token, not the body', async () => {
const event = await create({ Name: 'Not Yours', RoomId: 3, CreatorPlayerId: 999 })
expect(event.CreatorPlayerId).toBe(42)
})
test('POST /api/playerevents/v2 defaults an empty body rather than rejecting it', async () => {
const event = await create({})
expect(event).toMatchObject({
Name: 'Untitled Event',
Description: '',
RoomId: 0,
SubRoomId: null,
ClubId: null,
ImageName: null,
AttendeeCount: 1,
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: false,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
})
// A start with no end runs for an hour.
expect(Date.parse(event.EndTime) - Date.parse(event.StartTime)).toBe(HOUR)
})
test('GET /api/playerevents/v1/:eventId serves the bare event', async () => {
const res = await get(`/api/playerevents/v1/${upcoming.PlayerEventId}`)
expect(res.status).toBe(200)
// No envelope here — unlike the writes.
expect(await res.json()).toEqual(upcoming)
expect((await get('/api/playerevents/v1/999999')).status).toBe(404)
})
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
const res = await get(
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
)
expect(res.status).toBe(200)
const events = (await res.json()) as PlayerEvent[]
// Request order, not id order — and the missing id leaves no hole.
expect(events.map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
upcoming.PlayerEventId,
])
// No ids is an empty list, not every event.
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
})
test('GET /api/playerevents/v1/search matches name and description, skipping finished events', async () => {
const search = async (qs: string): Promise<PlayerEvent[]> =>
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
// Every term has to match, across name OR description.
expect((await search('?query=dungeons+escape')).map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
])
// …matched case-insensitively, and against the description too.
expect((await search('?query=upto%204%20players')).map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
])
// `pastEvent` matches on name but has already ended, so the browse query drops it.
const trig = await search('?query=trigonometry')
expect(trig.map((e) => e.PlayerEventId)).toEqual([upcoming.PlayerEventId])
expect(trig.map((e) => e.PlayerEventId)).not.toContain(pastEvent.PlayerEventId)
// Soonest first, and take/skip page through that order.
const all = await search('')
const starts = all.map((e) => e.StartTime)
expect([...starts].sort()).toEqual(starts)
expect(await search('?take=1')).toEqual([all[0]])
expect(await search('?skip=1&take=1')).toEqual([all[1]])
})
test('GET /api/playerevents/v1/searchlive serves what is running right now', async () => {
const res = await get('/api/playerevents/v1/searchlive')
expect(res.status).toBe(200)
const ids = ((await res.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)
expect(ids).toContain(liveEvent.PlayerEventId)
// Started in an hour / finished already — neither is live.
expect(ids).not.toContain(upcoming.PlayerEventId)
expect(ids).not.toContain(pastEvent.PlayerEventId)
})
test('GET /api/playerevents/v1/clubs is a bare array; /club/:id is a paged envelope', async () => {
// The client deserializes the multi-club form as a list — an envelope here fails
// with "expected:'[', actual:'{'". Do not unify the two.
const many = await get('/api/playerevents/v1/clubs?id=7&id=8')
expect(many.status).toBe(200)
const events = (await many.json()) as PlayerEvent[]
expect(events.map((e) => e.PlayerEventId)).toEqual([
liveEvent.PlayerEventId, // started an hour ago — soonest first
clubEvent.PlayerEventId,
])
// The single-club form does wrap its events with a paging cursor.
const one = await get('/api/playerevents/v1/club/7')
expect(one.status).toBe(200)
expect(await one.json()).toEqual({ ContinuationToken: '', Events: events })
// A club with no events, and the no-ids case.
expect(await (await get('/api/playerevents/v1/club/8')).json()).toEqual({
ContinuationToken: '',
Events: [],
})
expect(await (await get('/api/playerevents/v1/clubs')).json()).toEqual([])
})
test('GET /api/playerevents/v1/all lists the callers own events, auth-gated', async () => {
expect((await get('/api/playerevents/v1/all')).status).toBe(401)
const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as {
Created: PlayerEvent[]
Responses: unknown[]
}
const ids = mine.Created.map((e) => e.PlayerEventId)
expect(ids).toContain(upcoming.PlayerEventId)
// 43 created that one, not 42.
expect(ids).not.toContain(liveEvent.PlayerEventId)
// Finished events stay in the creator's own list — only the browse queries drop them.
expect(ids).toContain(pastEvent.PlayerEventId)
// Nothing records an RSVP yet.
expect(mine.Responses).toEqual([])
const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as {
Created: PlayerEvent[]
}
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
})
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
post('/api/playerevents/v1/respond', body, sub)
const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) })
const id = event.PlayerEventId
// The creator is Going from create, which is where the initial 1 comes from.
expect(event.AttendeeCount).toBe(1)
expect(await countGoing(env.DB, id)).toBe(1)
// 43 says Going → 2 attendees, and the envelope carries the updated event.
const res = await respond({ PlayerEventId: id, Type: 0 }, '43')
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
expect(body.PlayerEvent.AttendeeCount).toBe(2)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({
event_id: id,
player_id: 43,
status: 0,
})
// Changing the answer REPLACES it — one row per player, not a second RSVP.
const changed = await respond({ PlayerEventId: id, Type: 2 }, '43')
expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 })
expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43])
// Interested is a maybe — recorded, but not counted.
await respond({ PlayerEventId: id, Type: 1 }, '43')
expect(await countGoing(env.DB, id)).toBe(1)
// And the count sticks on the stored event, not just the response.
const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent
expect(fetched.AttendeeCount).toBe(1)
})
test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => {
const event = await create({ RoomId: 3, Name: 'Guarded' })
expect(
(
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/respond`, {
method: 'POST',
body: JSON.stringify({ PlayerEventId: event.PlayerEventId, Type: 0 }),
})
).status
).toBe(401)
// An unrecognized Type is rejected rather than defaulted — stored as Going it
// would silently inflate the count.
expect((await post('/api/playerevents/v1/respond', { PlayerEventId: 1, Type: 7 })).status).toBe(
400
)
expect((await post('/api/playerevents/v1/respond', { Type: 0 })).status).toBe(400)
expect((await post('/api/playerevents/v1/respond', {})).status).toBe(400)
expect(
(await post('/api/playerevents/v1/respond', { PlayerEventId: 999999, Type: 0 })).status
).toBe(404)
})
test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => {
const event = await create({
RoomId: 5,
SubRoomId: 6,
ClubId: 9,
Name: 'Original',
Description: 'Original description',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}`
expect(
(await exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', body: '{}' })).status
).toBe(401)
// 43 didn't create it.
expect((await post(path, { Name: 'Hijacked' }, '43')).status).toBe(403)
expect((await post('/api/playerevents/v2/999999', { Name: 'Nope' })).status).toBe(404)
const res = await post(path, { Name: 'Renamed' })
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Only the name moved; a partial post can't blank out the rest.
expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' })
// And it stuck.
expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual(
body.PlayerEvent
)
})
test('POST /api/playerevents/v2/:eventId clears a nullable id when the body sends null', async () => {
const event = await create({ RoomId: 5, SubRoomId: 6, ClubId: 9, Name: 'Clearable' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
// Nested form again, and an explicit null — absent leaves the value alone,
// null genuinely clears it.
PlayerEvent: { ClubId: null, ImageName: null },
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.ClubId).toBeNull()
expect(updated.ImageName).toBeNull()
expect(updated.SubRoomId).toBe(6)
})
test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => {
const event = await create({ RoomId: 5, Name: 'Fixed' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
PlayerEventId: 424242,
CreatorPlayerId: 43,
AttendeeCount: 500,
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.PlayerEventId).toBe(event.PlayerEventId)
expect(updated.CreatorPlayerId).toBe(42)
expect(updated.AttendeeCount).toBe(1)
})
})
describe('openapi', () => {
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'], …)` relationship routes contribute 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([
'DELETE /api/images/v1/deletesaved',
'GET /api/PlayerReporting/v1/moderationBlockDetails',
'GET /api/PlayerReporting/v1/voteToKickReasons',
'GET /api/activities/charades/v1/words/{activity}',
'GET /api/announcement/v1/get',
'GET /api/communityboard/v2/current',
'GET /api/config/v1/amplitude',
'GET /api/config/v1/azurespeech',
'GET /api/config/v1/backtrace',
'GET /api/config/v2',
'GET /api/consumables/v2/getUnlocked',
'GET /api/customAvatarItems/v1/featured',
'GET /api/customAvatarItems/v1/hot',
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
'GET /api/customAvatarItems/v1/isCreationEnabled',
'GET /api/customAvatarItems/v1/isRenderingEnabled',
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gameconfigs/v1/all',
'GET /api/images/v1/slideshow',
'GET /api/images/v2/named',
'GET /api/images/v3/feed/player/{playerId}',
'GET /api/images/v4/player/{playerId}',
'GET /api/images/v4/room/{roomId}',
'GET /api/images/v5/cheered/bulk',
'GET /api/images/v5/player/{playerId}',
'GET /api/images/v6',
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
'GET /api/inventions/v1/personaldetails/{inventionId}',
'GET /api/inventions/v1/room',
'GET /api/inventions/v1/tagfilters',
'GET /api/inventions/v1/toptoday',
'GET /api/inventions/v1/update',
'GET /api/inventions/v1/version',
'GET /api/inventions/v2/batch',
'GET /api/inventions/v2/mine',
'GET /api/inventions/v2/search',
'GET /api/inventions/v3/publish',
'GET /api/keepsakes/categories',
'GET /api/keepsakes/globalconfig',
'GET /api/keepsakes/rooms/{roomId}',
'GET /api/messages/v1/favoriteFriendOnlineStatus',
'GET /api/messages/v2/get',
'GET /api/playerReputation/v1/{id}',
'GET /api/playerReputation/v2/bulk',
'GET /api/playerevents/v1/all',
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
'GET /api/playerevents/v1/search',
'GET /api/playerevents/v1/searchlive',
'GET /api/playerevents/v1/tagfilters',
'GET /api/playerevents/v1/{eventId}',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/quickPlay/v1/getandclear',
'GET /api/relationships/mutualfriends',
'GET /api/relationships/v1/favorite',
'GET /api/relationships/v1/ignore',
'GET /api/relationships/v1/mute',
'GET /api/relationships/v1/unfavorite',
'GET /api/relationships/v1/unignore',
'GET /api/relationships/v1/unmute',
'GET /api/relationships/v2/acceptfriendrequest',
'GET /api/relationships/v2/addfriend',
'GET /api/relationships/v2/get',
'GET /api/relationships/v2/removefriend',
'GET /api/relationships/v2/sendfriendrequest',
'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room',
'GET /api/rooms/v1/filters',
'GET /api/versioncheck/v4',
'GET /voice/config',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v3/create',
'POST /api/avatar/v2/gifts/generate',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v6/save',
'POST /api/messages/v2/send',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/respond',
'POST /api/playerevents/v2',
'POST /api/playerevents/v2/{eventId}',
'POST /api/players/v1/progression/bulk',
'POST /api/players/v2/progression/bulk',
'POST /api/playerwarnings',
'POST /api/relationships/v1/favorite',
'POST /api/relationships/v1/ignore',
'POST /api/relationships/v1/mute',
'POST /api/relationships/v1/unfavorite',
'POST /api/relationships/v1/unignore',
'POST /api/relationships/v1/unmute',
'POST /api/relationships/v2/acceptfriendrequest',
'POST /api/relationships/v2/addfriend',
'POST /api/relationships/v2/removefriend',
'POST /api/relationships/v2/sendfriendrequest',
'POST /api/rooms/v1/verifyRole',
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/v1/progression/bulk',
])
// Every operation carries a summary — an undescribed one renders as a bare path.
for (const [path, ops] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(ops)) {
expect(op.summary, `${method.toUpperCase()} ${path} has no summary`).toBeTruthy()
}
}
})
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d schema
// used in a response emits a $ref this hono-openapi + zod v4 setup does not always
// hoist, leaving a dangling reference that breaks the docs UI.
test('the spec has no $refs', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const raw = await res.text()
expect(raw.match(/\$ref/g)).toBeNull()
})
// `z.int()` carries the safe-integer range as its bounds, which Scalar would
// otherwise show as the example value for every integer field (-9007199254740991).
// withCleanSpec() supplies a placeholder instead; this guards the wrapper staying
// wired up.
test('integer fields carry a placeholder example', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const raw = await res.text()
const integers = raw.match(/"type":"integer"/g) ?? []
expect(integers.length).toBeGreaterThan(0)
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
})
})