Files
recflare/apps/rooms/src/test/integration/api.test.ts
T
2026-09-22 19:14:36 -04:00

5415 lines
220 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,
createExecutionContext,
createScheduledController,
env,
SELF,
waitOnExecutionContext,
} from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'
import {
AUDIT_LOG_SCHEMA_DDL,
createRoomInstance,
getRoomInstance,
MessageType,
NOTIFICATION_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_INVITE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
import { NotificationType } from '../../../../notify/src/notification-types'
import importRooms from '../../../static/ImportRooms.json'
import { scheduled } from '../../rooms.app'
import type { Room } from '@repo/domain'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none — what a plain player's looks like to the role gates.
async function bearer(
sub: string,
roles?: string[],
// The client build the token was minted for (`rn.ver`), which is what
// `/featuredrooms/current` gates on. Omitted by default, like a token from a grant that
// posted no `ver`.
version?: 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 }),
...(version && { 'rn.ver': version }),
})
)}`
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)}` }
}
/**
* Put a player in a room, the way the `match` heartbeat would — the save routes read this
* to decide whether a non-creator may see the room's history. `expired` writes a row that
* has already lapsed, which reads back as no presence at all.
*/
async function putInRoom(
accountId: number,
roomId: number,
{ expired = false }: { expired?: boolean } = {}
): Promise<void> {
const now = Math.floor(Date.now() / 1000)
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId: 1000000 + roomId, roomId, subRoomId: roomId },
expiresAt: expired ? now - 1 : now + 900,
})
)
.run()
}
/** Take a player back out of whatever room they were in. */
async function clearPresence(accountId: number): Promise<void> {
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(accountId).run()
}
// Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations).
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (read by the photon access-token handler).
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Message store (owned by the api worker) — a room-role invite is written there before
// it is pushed, so the invited player can read it back from their inbox.
for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The audit log the staff takedown writes to. Owned by `api`'s migrations; built here
// directly, the way `notify`'s tests do.
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Vote-to-kick ballots (owned by `api`) and game invites (owned by `match`) — the cron
// here sweeps the stale ones.
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
// Accounts table (owned by the auth worker) — provisioning a dorm reads the username
// to name the room. Seed the player `dormroom/me` provisions a fresh dorm for.
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
)`
).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 999, username: 'Dormer' }))
.run()
// Relationship table (owned by the api worker) — `visitedby/:playerId` reads it to
// check the caller is a friend of the player whose history they're asking for.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS relationship (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relationship_type INTEGER NOT NULL DEFAULT 0
)`
).run()
const insertRel = env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([
insertRel.bind(791, 790, 3), // friends — the caller (791) is the requester
insertRel.bind(790, 792, 3), // friends — the caller (792) is the target
insertRel.bind(793, 790, 1), // request out, not accepted — 793 is NOT a friend
])
})
describe('rooms endpoints', () => {
it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ service: 'rooms', status: 'ok' })
})
it('GET /rooms/1 returns the seeded dorm with its SubRoom scene', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/1?include=1325`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
RoomId: number
Name: string
IsDorm: boolean
SubRooms: Array<{ UnitySceneId: string }>
}
expect(body).toMatchObject({ RoomId: 1, Name: 'DormRoom', IsDorm: true })
expect(body.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
})
// None of these are stored — the seed blobs predate the keys — so they are defaulted on
// read. The client's room DTO always carries them, and an ABSENT key is not the same as a
// zero/null one to its parser. `FriendlyName` is deliberately NOT among them: this server
// does not serve a display name apart from `Name`, and migration 0017 strips any stored one.
it('GET /rooms/:id carries BoostCount, CurrentSnapshotId and CCU, and no FriendlyName', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/1`)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body).toHaveProperty('BoostCount', 0)
expect(body).toHaveProperty('CurrentSnapshotId', null)
expect(body).toHaveProperty('CCU', null)
expect(body).not.toHaveProperty('FriendlyName')
})
// Pinned whole: these are the numbers the client's publish UI counts against, and
// `error: null` / `error_id` is a different envelope from the room mutations' — a
// "cleanup" that unified the two would break the client silently.
it('GET /publishState/configs returns the republish limits', async () => {
const res = await SELF.fetch(`${ORIGIN}/publishState/configs`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
value: {
UpdateMaxCount: 3,
UpdateRollingWindowInDays: 365,
UpdateExpirationInDays: 30,
UpdateCooldownInDays: 45,
},
success: true,
error_id: null,
error: null,
})
})
// Stub. Registered (not 404) matters more than the body: the client asks for this on
// room entry, and an unregistered path stalls the load rather than erroring visibly.
it('GET /rooms/:id/experience/player returns [] for any room', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/92/experience/player`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
// Stub, same reasoning: the profile asks for a showcase for any player, and an
// unregistered path leaves the profile half-drawn. No auth, and no such player is
// still [] rather than a 404.
it('GET /showcase/:playerId returns [] for any player', async () => {
const res = await SELF.fetch(`${ORIGIN}/showcase/205`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
const unknown = await SELF.fetch(`${ORIGIN}/showcase/99999`)
expect(unknown.status).toBe(200)
expect(await unknown.json()).toEqual([])
})
it('GET /showcase/:playerId 404s on a non-numeric id', async () => {
expect((await SELF.fetch(`${ORIGIN}/showcase/abc`)).status).toBe(404)
})
it('GET /rooms/:id 404s for a room not in D1', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/99999`)
expect(res.status).toBe(404)
})
it('GET /rooms?name= resolves a real room case-insensitively', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms?name=reccenter`)
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({ Name: 'RecCenter' })
})
it('GET /rooms?name= returns {} when nothing matches', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms?name=NoSuchRoomHere`)
expect(await res.json()).toEqual({})
})
it('GET /rooms with no id or name returns 400', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms`)
expect(res.status).toBe(400)
})
it('GET /rooms/bulk?id= returns the matching rooms', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/bulk?id=1,2`)
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ RoomId: number; Name: string }>
expect(body.map((r) => r.Name).sort()).toEqual(['DormRoom', 'RecCenter'])
})
it('GET /rooms/bulk takes repeated id params, like the POST form', async () => {
// The client spells the GET's id list the same way it spells the POST body's — one
// `id` per room. Reading only the first left it rendering a single room.
const res = await SELF.fetch(
`${ORIGIN}/rooms/bulk?id=1&id=2&id=999999&excludePrivateRooms=False`
)
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ Name: string }>
expect(body.map((r) => r.Name).sort()).toEqual(['DormRoom', 'RecCenter'])
})
it('GET /rooms/bulk?name=RecCenter returns [RecCenter]', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/bulk?name=RecCenter`)
const body = (await res.json()) as Array<{ Name: string }>
expect(body.map((r) => r.Name)).toEqual(['RecCenter'])
})
it('POST /rooms/bulk takes repeated id fields in a form body', async () => {
const post = async (body: string) =>
SELF.fetch(`${ORIGIN}/rooms/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
// The client's form: one `id` per room, plus the filter. An id that isn't in D1 is
// simply absent, so the answer can be shorter than the request.
const res = await post('id=1&id=2&id=999999&excludePrivateRooms=False')
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ RoomId: number; Name: string }>
expect(body.map((r) => r.Name).sort()).toEqual(['DormRoom', 'RecCenter'])
// Comma-separated values inside an `id` work too, as they do on the GET.
const commas = (await (await post('id=1,2')).json()) as Array<{ Name: string }>
expect(commas.map((r) => r.Name).sort()).toEqual(['DormRoom', 'RecCenter'])
// `excludePrivateRooms=True` drops the non-public rooms — the dorm here.
const publicOnly = (await (await post('id=1&id=2&excludePrivateRooms=True')).json()) as Array<{
Name: string
Accessibility: number
}>
expect(publicOnly.map((r) => r.Name)).toEqual(['RecCenter'])
expect(publicOnly.every((r) => r.Accessibility === 1)).toBe(true)
// No ids is an empty array, not a 400 — unlike the GET, which needs an `id` or `name`.
expect(await (await post('excludePrivateRooms=False')).json()).toEqual([])
// D1 binds one parameter per id and caps a query at 100, so a longer list is refused
// rather than split — a caller asking about more than a hundred rooms at once has lost
// track of what it is rendering.
const idList = (n: number) => Array.from({ length: n }, (_, i) => 500000 + i)
expect(
(
await post(
idList(100)
.map((id) => `id=${id}`)
.join('&')
)
).status
).toBe(200)
const overCap = await post(
idList(101)
.map((id) => `id=${id}`)
.join('&')
)
expect(overCap.status).toBe(400)
expect(await overCap.json()).toBe('At most 100 room ids may be looked up at once')
// The GET form has the same cap, counting the ids inside its comma-separated `id`.
const overCapGet = await SELF.fetch(`${ORIGIN}/rooms/bulk?id=${idList(101).join(',')}`)
expect(overCapGet.status).toBe(400)
})
it('GET /rooms/ownedby/me is auth-gated and scoped to the caller', async () => {
// No token → 401, no stub-account fallback (would otherwise leak account 1).
const noAuth = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`)
expect(noAuth.status).toBe(401)
// Account 1 owns all the seeded rooms, but the dorm is excluded here.
const mine = (await (
await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('1') })
).json()) as Array<{ RoomId: number; IsDorm?: boolean }>
expect(mine.length).toBe(importRooms.filter((r) => r.IsDorm !== true).length)
// The dorm (RoomId 1) is auto-provisioned, so it never appears.
expect(mine.some((r) => r.RoomId === 1 || r.IsDorm === true)).toBe(false)
// A different account owns none of them.
const other = (await (
await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('999') })
).json()) as unknown[]
expect(other).toEqual([])
})
it('GET /dormroom/me serves the caller’s dorm id, not the room', async () => {
// No token → 401. Without this the endpoint would hand out (and provision) a dorm
// for whichever account a fallback picked.
const noAuth = await SELF.fetch(`${ORIGIN}/dormroom/me`)
expect(noAuth.status).toBe(401)
// Account 1 owns the seeded dorm (RoomId 1). The body is that id ALONE — a bare
// JSON number, not the room and not an object wrapping the id.
const res = await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('1') })
expect(res.status).toBe(200)
expect(await res.json()).toBe(1)
// It is the id of a room that really is the caller's dorm — the caller fetches the
// room itself from /rooms/{id}.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/1`)).json()) as {
RoomId: number
IsDorm: boolean
CreatorAccountId: number
}
expect(room).toMatchObject({ RoomId: 1, IsDorm: true, CreatorAccountId: 1 })
// A player who has never entered their dorm gets one provisioned rather than a
// 404 — the get-or-create still happens, only the payload shrank. And it belongs
// to THEM, not the template dorm they were cloned from.
const fresh = (await (
await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('999') })
).json()) as number
expect(typeof fresh).toBe('number')
expect(fresh).not.toBe(1)
const provisioned = (await (await SELF.fetch(`${ORIGIN}/rooms/${fresh}`)).json()) as {
IsDorm: boolean
CreatorAccountId: number
}
expect(provisioned).toMatchObject({ IsDorm: true, CreatorAccountId: 999 })
// Idempotent: the second call is the same dorm, not a second one.
const again = (await (
await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('999') })
).json()) as number
expect(again).toBe(fresh)
})
// The website's "My rooms" list is a browser calling this worker from another origin,
// so a response without CORS headers is one the browser throws away — and the page
// can't tell that apart from the server being down. Pinned on the preflight too: the
// SPA sends `Authorization`, which makes even the GET a preflighted request.
it('answers CORS so the website can read a room list from the browser', async () => {
const preflight = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'GET',
'access-control-request-headers': 'authorization',
},
})
expect(preflight.status).toBe(204)
expect(preflight.headers.get('access-control-allow-origin')).toBe('*')
expect(preflight.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'authorization'
)
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
headers: { ...(await bearer('1')), origin: 'https://www.example.com' },
})
expect(res.status).toBe(200)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
it('GET /rooms/ownedby|createdby/me lists the caller’s UNPUBLISHED rooms too', async () => {
// "My Rooms" is the owner's own list, not a catalog: it must show a room that
// isn't public yet, or a freshly created room (which starts Private — see
// cloneRoom) would be invisible to the person who just made it. Only the
// PUBLIC-facing `ownedby/:accountId` profile list filters on accessibility.
const headers = {
...(await bearer('804')),
'Content-Type': 'application/x-www-form-urlencoded',
}
await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
method: 'POST',
headers,
body: new URLSearchParams({ name: 'MyUnpublishedRoom' }).toString(),
})
const listOf = async (path: string) =>
(await (await SELF.fetch(`${ORIGIN}${path}`, { headers })).json()) as Array<{
Name: string
Accessibility: number
}>
for (const path of [
'/rooms/ownedby/me',
'/rooms/createdby/me',
'/roomserver/rooms/createdby/me',
]) {
const mine = await listOf(path)
const room = mine.find((r) => r.Name === 'MyUnpublishedRoom')
expect(room, `${path} must list the caller's unpublished room`).toBeDefined()
expect(room!.Accessibility).toBe(0)
}
// The same room is absent from the account's PUBLIC profile list.
const publicList = (await (await SELF.fetch(`${ORIGIN}/rooms/ownedby/804`)).json()) as Array<{
Name: string
}>
expect(publicList.some((r) => r.Name === 'MyUnpublishedRoom')).toBe(false)
})
it('GET /rooms/contributedby/me lists rooms the caller owns or has a role in', async () => {
const seed = (data: Record<string, unknown>) =>
env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)).run()
// A room somebody else made, where 820 is a co-owner...
await seed({
RoomId: 30401,
Name: 'ContribCoOwner',
CreatorAccountId: 821,
Accessibility: 1,
SubRooms: [],
Roles: [
{ AccountId: 821, Role: 255 },
{ AccountId: 820, Role: 30 },
],
})
// ...one where they're only a host (every tier counts, not just owner-level)...
await seed({
RoomId: 30402,
Name: 'ContribHost',
CreatorAccountId: 821,
// Unpublished: a contributor works on the room before it goes public, so
// accessibility is not filtered here.
Accessibility: 0,
SubRooms: [],
Roles: [{ AccountId: 820, Role: 10 }],
})
// ...one they created themselves, whose Roles name them as Creator (matched by BOTH
// halves of the query, so it must still appear exactly once)...
await seed({
RoomId: 30403,
Name: 'ContribOwn',
CreatorAccountId: 820,
Accessibility: 1,
SubRooms: [],
Roles: [{ AccountId: 820, Role: 255 }],
})
// ...one they created that names nobody in Roles at all — the older rooms have no
// Roles key, and those reach the list on the creator half alone...
await seed({
RoomId: 30406,
Name: 'ContribOwnNoRoles',
CreatorAccountId: 820,
Accessibility: 1,
SubRooms: [],
})
// ...and their dorm, which stays out: auto-provisioned, not a room they made.
await seed({
RoomId: 30407,
Name: "@player820's Dorm",
CreatorAccountId: 820,
IsDorm: true,
Accessibility: 2,
SubRooms: [],
Roles: [{ AccountId: 820, Role: 255 }],
})
// ...one they have nothing to do with, and one with no Roles key at all (the older
// seeded rooms have none — json_each must drop them, not error).
await seed({
RoomId: 30404,
Name: 'ContribOther',
CreatorAccountId: 821,
Accessibility: 1,
SubRooms: [],
Roles: [{ AccountId: 822, Role: 30 }],
})
await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] })
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
headers: await bearer('820'),
})
expect(res.status).toBe(200)
const rooms = (await res.json()) as Array<{ RoomId: number; Name: string }>
// A bare array of the canonical room DTO — no envelope, no paging wrapper.
expect(Array.isArray(rooms)).toBe(true)
expect(rooms.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([30401, 30402, 30403, 30406])
// Their own rooms are IN — the list overlaps createdby/me rather than coming back
// empty for someone who only ever built their own — but the dorm is not.
expect(rooms.some((r) => r.RoomId === 30407)).toBe(false)
expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) })
// A player who contributes to nothing gets an empty array, not a 404.
const none = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
headers: await bearer('829'),
})
expect(await none.json()).toEqual([])
// Auth-scoped: `me` is the token, so no token is a 401.
expect((await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`)).status).toBe(401)
// The DB is shared across this file, and these are the only player-made public rooms
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
})
it('GET /rooms/:roomId/experience serves the fixed XP settings, no auth', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/experience`)
expect(res.status).toBe(200)
// A bare two-key object — no `{ success, error, value }` envelope around it. Disabled:
// no room awards XP here, and DailyLimit is the cap that would apply if one did.
expect(await res.json()).toEqual({ Enabled: false, DailyLimit: 1000 })
// Nothing is stored per room, so every room answers the same — including one that
// doesn't exist, which is never looked up.
expect(await (await SELF.fetch(`${ORIGIN}/rooms/77/experience`)).json()).toEqual({
Enabled: false,
DailyLimit: 1000,
})
expect(await (await SELF.fetch(`${ORIGIN}/rooms/99999/experience`)).json()).toEqual({
Enabled: false,
DailyLimit: 1000,
})
// The id is digits-only, like the other room-scoped routes.
expect((await SELF.fetch(`${ORIGIN}/rooms/abc/experience`)).status).toBe(404)
})
it('GET /rooms/ownedby/:id returns an account public rooms (no auth)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/1`)
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{
RoomId: number
Accessibility: number
IsDorm?: boolean
CreatorAccountId: number
}>
expect(body.length).toBeGreaterThan(0)
// Only public, non-dorm rooms owned by account 1 — the private dorm (RoomId 1)
// is excluded.
expect(
body.every((r) => r.Accessibility === 1 && r.IsDorm !== true && r.CreatorAccountId === 1)
).toBe(true)
expect(body.some((r) => r.RoomId === 1)).toBe(false)
// An account that owns no public rooms → empty array.
expect(await (await SELF.fetch(`${ORIGIN}/rooms/ownedby/999`)).json()).toEqual([])
})
it('GET /rooms/search returns a paginated { Results, TotalResults }', async () => {
// Name-term search resolves a known public room.
const res = await SELF.fetch(`${ORIGIN}/rooms/search?query=reccenter&skip=0&take=100`)
expect(res.status).toBe(200)
const body = (await res.json()) as { Results: Array<{ Name: string }>; TotalResults: number }
expect(body.TotalResults).toBeGreaterThanOrEqual(1)
expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true)
})
it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => {
const suggest = async (query: string, extra = '') =>
(await (
await SELF.fetch(
`${ORIGIN}/rooms/autocomplete_search?query=${encodeURIComponent(query)}${extra}`
)
).json()) as string[]
// A bare array of STRINGS — not rooms, not an envelope.
const rec = await suggest('rec', '&take=4&searchSessionId=abc-123')
expect(Array.isArray(rec)).toBe(true)
for (const s of rec) expect(typeof s).toBe('string')
expect(rec).toContain('RecCenter')
expect(rec.length).toBeLessThanOrEqual(4)
// Every suggestion finds something when submitted — the point of the endpoint.
for (const term of rec) {
const found = (await (
await SELF.fetch(`${ORIGIN}/rooms/search?query=${encodeURIComponent(term)}`)
).json()) as { TotalResults: number }
expect(found.TotalResults, `"${term}" must find rooms`).toBeGreaterThan(0)
}
// Tags are suggested with their `#`, and a `#` query suggests tags only.
const tags = await suggest('#rro')
expect(tags).toEqual(['#rro'])
expect(tags.every((t) => t.startsWith('#'))).toBe(true)
// `take` caps the list; an empty query suggests nothing rather than everything.
expect((await suggest('e', '&take=2')).length).toBeLessThanOrEqual(2)
expect(await suggest('')).toEqual([])
expect(await suggest('zzzznothingmatchesthis')).toEqual([])
// Deterministic: the same query suggests the same things in the same order.
expect(await suggest('rec')).toEqual(rec)
// Dorms are excluded, exactly as they are from search.
expect(await suggest('dormroom')).toEqual([])
})
it('GET /rooms/search excludes dorms and respects pagination shape', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/search?query=dormroom`)
expect(res.status).toBe(200)
const body = (await res.json()) as { Results: unknown[]; TotalResults: number }
// The dorm is non-public/dorm, so a name search for it returns nothing.
expect(body).toEqual({ Results: [], TotalResults: 0 })
})
it('GET /rooms/search?query=#tag returns 200 (tag search)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/search?query=%23Quest+%23recroomoriginal`)
expect(res.status).toBe(200)
const body = (await res.json()) as { Results: unknown[]; TotalResults: number }
expect(Array.isArray(body.Results)).toBe(true)
expect(typeof body.TotalResults).toBe('number')
})
it('GET /rooms/search aliases #recroomoriginal to the rro tag', async () => {
// Rooms are tagged `rro`, not `recroomoriginal` — the alias bridges them.
const aliased = (await (
await SELF.fetch(`${ORIGIN}/rooms/search?query=%23recroomoriginal`)
).json()) as { TotalResults: number }
const direct = (await (await SELF.fetch(`${ORIGIN}/rooms/search?query=%23rro`)).json()) as {
TotalResults: number
}
expect(aliased.TotalResults).toBe(direct.TotalResults)
expect(aliased.TotalResults).toBeGreaterThan(0)
})
it('GET /rooms/search?query=#community serves rooms the Coach account did not create', async () => {
// The browse chip's word reaches search as a tag term, but no room CARRIES a
// `community` tag — it is the same pseudo-tag `/rooms/hot?tag=community` applies, so it
// filters on who MADE the room. Every seeded room belongs to Coach (account 1), so it
// finds nothing until another account makes something.
type Page = { Results: Array<{ Name: string }>; TotalResults: number }
const search = async (query: string): Promise<Page> =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/search?query=${encodeURIComponent(query)}&take=100`)
).json()) as Page
expect(await search('#community')).toEqual({ Results: [], TotalResults: 0 })
const seeded: number[] = []
const seed = async (room: Record<string, unknown>) => {
seeded.push(Number(room.RoomId))
await seedRoomWithSubRooms(env.DB, {
Accessibility: 1,
IsDorm: false,
CreatorAccountId: 2,
...room,
})
}
await seed({ RoomId: 9201, Name: 'CommunityHorrorHouse' })
await seed({ RoomId: 9202, Name: 'CommunityArcade' })
// Coach's own rooms stay out, and so do non-public ones as everywhere else in search.
await seed({ RoomId: 9203, Name: 'CommunityCoachRoom', CreatorAccountId: 1 })
await seed({ RoomId: 9204, Name: 'CommunityUnlisted', Accessibility: 2 })
const found = await search('#community')
expect(found.Results.map((r) => r.Name)).toEqual(['CommunityHorrorHouse', 'CommunityArcade'])
expect(found.TotalResults).toBe(2)
// The client sends the chip with a trailing space (`?query=%23community+`), which the
// term split has to swallow rather than search for an empty second term.
expect(await search('#community ')).toEqual(found)
// It NARROWS the rest of the query rather than replacing it: a name term still applies,
// and so does a real tag, which is still the SQL lookup it always was.
expect((await search('#community arcade')).Results.map((r) => r.Name)).toEqual([
'CommunityArcade',
])
expect(await search('#community #rro')).toEqual({ Results: [], TotalResults: 0 })
// Leave the shared dataset as it was for the tests that follow.
const ids = seeded.join(',')
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
})
it('GET /rooms/favoritedby/me returns a bare array of the caller favorited rooms (auth-scoped)', async () => {
const headers = await bearer('777')
// Auth-gated — no token is a 401, never account 1's favorites.
expect((await SELF.fetch(`${ORIGIN}/rooms/favoritedby/me`)).status).toBe(401)
// No favorites yet → empty array.
const empty = (await (
await SELF.fetch(`${ORIGIN}/rooms/favoritedby/me`, { headers })
).json()) as unknown[]
expect(empty).toEqual([])
// Favorite two real rooms, then they come back.
for (const id of [2, 12]) {
await SELF.fetch(`${ORIGIN}/rooms/${id}/interactionby/me/favorite`, {
method: 'PUT',
headers,
})
}
const body = (await (
await SELF.fetch(`${ORIGIN}/rooms/favoritedby/me?skip=0&take=100`, { headers })
).json()) as Array<{ RoomId: number }>
expect(body.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([2, 12])
// Un-favoriting one drops it from the list.
await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/favorite`, { method: 'PUT', headers })
const afterUnfav = (await (
await SELF.fetch(`${ORIGIN}/rooms/favoritedby/me`, { headers })
).json()) as Array<{ RoomId: number }>
expect(afterUnfav.map((r) => r.RoomId)).toEqual([12])
// Scoped per player — a different account sees none.
const other = (await (
await SELF.fetch(`${ORIGIN}/rooms/favoritedby/me`, { headers: await bearer('778') })
).json()) as unknown[]
expect(other).toEqual([])
})
it('GET /rooms/visitedby/me returns a bare array of rooms the caller has interacted with (auth-scoped)', async () => {
const headers = await bearer('779')
// No interactions yet → empty array.
const empty = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers })
).json()) as unknown[]
expect(empty).toEqual([])
// Interacting (cheer/favorite) records a last-visit on those rooms.
await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, { method: 'PUT', headers })
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'PUT', headers })
const body = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me?skip=0&take=100`, { headers })
).json()) as Array<{ RoomId: number }>
expect(body.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([2, 12])
// Un-cheering still counts as visited (the interaction row persists).
await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, { method: 'PUT', headers })
const afterUncheer = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers })
).json()) as unknown[]
expect(afterUncheer.length).toBe(2)
// Scoped per player — a different account sees none.
const other = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: await bearer('780') })
).json()) as unknown[]
expect(other).toEqual([])
})
it('GET /rooms/visitedby/:playerId serves a friend’s visited rooms and 403s everyone else', async () => {
// Give 790 a visit history (cheering/favoriting stamps a last-visit).
const subject = await bearer('790')
await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, {
method: 'PUT',
headers: subject,
})
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, {
method: 'PUT',
headers: subject,
})
// A mutual friend reads it — a bare array, regardless of which side of the
// relationship row the caller sits on.
for (const friend of ['791', '792']) {
const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, {
headers: await bearer(friend),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ RoomId: number }>
expect(body.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([2, 12])
}
// Paginated via skip/take.
const page = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/790?skip=0&take=1`, {
headers: await bearer('791'),
})
).json()) as unknown[]
expect(page.length).toBe(1)
// Your own history is readable by id, not just via `me`.
const own = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, { headers: subject })
).json()) as unknown[]
expect(own.length).toBe(2)
// A pending request is not a friendship, and a stranger is not either → 403.
for (const outsider of ['793', '794']) {
const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, {
headers: await bearer(outsider),
})
expect(res.status).toBe(403)
}
// No token at all → 401, never a fallback account.
const anon = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`)
expect(anon.status).toBe(401)
// `visitedby/me` still routes to the literal handler, not the id pattern.
const me = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: subject })
).json()) as unknown[]
expect(me.length).toBe(2)
})
it('GET /rooms/hot returns a paginated { Results, TotalResults } of public rooms', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
Results: Array<{ RoomId: number; IsDorm?: boolean }>
TotalResults: number
}
expect(body.Results.length).toBeGreaterThan(0)
expect(body.TotalResults).toBeGreaterThanOrEqual(body.Results.length)
// The dorm (RoomId 1) is non-public, so it's never in the feed.
expect(body.Results.some((r) => r.RoomId === 1 || r.IsDorm === true)).toBe(false)
})
it('GET /rooms/hot?tag=rro filters to rro-tagged rooms', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/hot?tag=rro&skip=0&take=100`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
Results: Array<{ Name: string; Tags?: Array<{ Tag: string }> }>
TotalResults: number
}
expect(body.Results.length).toBeGreaterThan(0)
// Every result carries the rro tag, and a known rro room is present.
expect(body.Results.every((r) => (r.Tags ?? []).some((t) => t.Tag === 'rro'))).toBe(true)
expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true)
})
it('GET /rooms/hot respects take pagination (TotalResults is the full count)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/hot?tag=rro&skip=0&take=2`)
const body = (await res.json()) as { Results: unknown[]; TotalResults: number }
expect(body.Results.length).toBeLessThanOrEqual(2)
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
})
it('GET /rooms/hot ranks rooms by the live presence in their instances', async () => {
const feed = async (): Promise<number[]> =>
(
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)).json()) as {
Results: Array<{ RoomId: number }>
}
).Results.map((r) => r.RoomId)
// Two rooms from the tail of the engagement-ordered feed, so any move to the
// front can only come from presence.
const before = await feed()
const busiest = before[before.length - 1]
const quieter = before[before.length - 2]
// Two players in two different instances of `busiest`, one in `quieter`, plus a
// lobby presence (no instance) that must not count for anyone.
const expiresAt = Math.floor(Date.now() / 1000) + 900
const seed = env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
await env.DB.batch(
[
{ accountId: 90001, roomInstance: { roomInstanceId: 1000901, roomId: busiest } },
{ accountId: 90002, roomInstance: { roomInstanceId: 1000902, roomId: busiest } },
{ accountId: 90003, roomInstance: { roomInstanceId: 1000903, roomId: quieter } },
{ accountId: 90004, roomInstance: null },
].map((p) => seed.bind(JSON.stringify({ ...p, expiresAt })))
)
expect((await feed()).slice(0, 2)).toEqual([busiest, quieter])
// Expired presence doesn't count — the feed falls back to engagement order.
await env.DB.prepare(
`UPDATE presence SET data = json_set(data, '$.expiresAt', ?1)
WHERE account_id IN (90001, 90002, 90003, 90004)`
)
.bind(Math.floor(Date.now() / 1000) - 1)
.run()
expect(await feed()).toEqual(before)
await env.DB.prepare(
'DELETE FROM presence WHERE account_id IN (90001, 90002, 90003, 90004)'
).run()
})
it('GET /rooms/curated_playlists is an empty list, not a 404', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/curated_playlists`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
it('GET /rooms/carousel/rising serves only rooms players are in, busiest first', async () => {
const rising = async (qs = '?skip=0&take=100') =>
(await (await SELF.fetch(`${ORIGIN}/rooms/carousel/rising${qs}`)).json()) as {
Results: Array<{ RoomId: number; IsDorm?: boolean }>
TotalResults: number
}
// Nobody is anywhere in the fixture, and an empty carousel is the honest answer —
// this feed does NOT fall back to engagement the way /rooms/hot does.
expect(await rising()).toEqual({ Results: [], TotalResults: 0 })
// Pick from the tail of the hot feed so the order below can only come from presence.
const hot = (
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)).json()) as {
Results: Array<{ RoomId: number }>
}
).Results.map((r) => r.RoomId)
const busiest = hot[hot.length - 1]
const quieter = hot[hot.length - 2]
const expiresAt = Math.floor(Date.now() / 1000) + 900
const seed = env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
await env.DB.batch(
[
{ accountId: 90101, roomInstance: { roomInstanceId: 1001001, roomId: busiest } },
{ accountId: 90102, roomInstance: { roomInstanceId: 1001002, roomId: busiest } },
{ accountId: 90103, roomInstance: { roomInstanceId: 1001003, roomId: quieter } },
// The dorm nobody may list, and a lobby presence in no room at all: neither
// puts a room in the carousel.
{ accountId: 90104, roomInstance: { roomInstanceId: 1001004, roomId: 1 } },
{ accountId: 90105, roomInstance: null },
].map((p) => seed.bind(JSON.stringify({ ...p, expiresAt })))
)
const busy = await rising()
expect(busy.Results.map((r) => r.RoomId)).toEqual([busiest, quieter])
expect(busy.TotalResults).toBe(2)
expect(busy.Results.some((r) => r.RoomId === 1 || r.IsDorm === true)).toBe(false)
// Paged like its sibling feeds: TotalResults stays the full count.
expect(await rising('?skip=0&take=1')).toMatchObject({ TotalResults: 2 })
expect((await rising('?skip=0&take=1')).Results.map((r) => r.RoomId)).toEqual([busiest])
expect((await rising('?skip=1&take=100')).Results.map((r) => r.RoomId)).toEqual([quieter])
// Presence that has expired is nobody standing there.
await env.DB.prepare(
`UPDATE presence SET data = json_set(data, '$.expiresAt', ?1)
WHERE account_id BETWEEN 90101 AND 90105`
)
.bind(Math.floor(Date.now() / 1000) - 1)
.run()
expect(await rising()).toEqual({ Results: [], TotalResults: 0 })
await env.DB.prepare('DELETE FROM presence WHERE account_id BETWEEN 90101 AND 90105').run()
})
it('GET /rooms/hot aliases #recroomoriginal to the rro tag', async () => {
const aliased = (await (
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=recroomoriginal`)
).json()) as { TotalResults: number }
const direct = (await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=rro`)).json()) as {
TotalResults: number
}
expect(aliased.TotalResults).toBe(direct.TotalResults)
expect(aliased.TotalResults).toBeGreaterThan(0)
})
it('GET /rooms/hot?tag=new serves player-made rooms newest-first (pseudo-tag)', async () => {
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
const feed = async (): Promise<Feed> =>
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=0&take=100`)).json()) as Feed
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
// No room carries a `new` tag, and every seeded room is a Rec Room Original — so
// the feed is empty until a player makes something.
expect(await feed()).toEqual({ Results: [], TotalResults: 0 })
const seeded: number[] = []
const seed = async (room: Record<string, unknown>) => {
seeded.push(Number(room.RoomId))
await seedRoomWithSubRooms(env.DB, { Accessibility: 1, IsDorm: false, IsRRO: false, ...room })
}
// Two player-made public rooms and one that isn't public.
await seed({ RoomId: 9001, Name: 'OlderPlayerRoom', CreatedAt: '2026-07-01T00:00:00Z' })
await seed({ RoomId: 9002, Name: 'NewerPlayerRoom', CreatedAt: '2026-07-02T00:00:00Z' })
await seed({
RoomId: 9003,
Name: 'UnlistedPlayerRoom',
CreatedAt: '2026-07-03T00:00:00Z',
Accessibility: 2,
})
// Newest first, and the non-public room is excluded as it is everywhere else.
expect(await feed()).toMatchObject({
Results: [{ Name: 'NewerPlayerRoom' }, { Name: 'OlderPlayerRoom' }],
TotalResults: 2,
})
// An RRO stays out even when it's the newest room in the database — by the flag,
// or by the auto-derived `rro` tag alone.
await seed({
RoomId: 9004,
Name: 'BrandNewRRO',
CreatedAt: '2026-07-04T00:00:00Z',
IsRRO: true,
})
await seed({
RoomId: 9005,
Name: 'TaggedRRO',
CreatedAt: '2026-07-05T00:00:00Z',
Tags: [{ Tag: 'rro', Type: 2 }],
})
expect(await names()).toEqual(['NewerPlayerRoom', 'OlderPlayerRoom'])
// Paging comes off the same order.
const page = (await (
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=1&take=1`)
).json()) as Feed
expect(page).toMatchObject({ Results: [{ Name: 'OlderPlayerRoom' }], TotalResults: 2 })
// Leave the shared feeds as they were for the tests that follow.
const ids = seeded.join(',')
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
})
it('GET /rooms/hot?tag=community serves rooms the Coach account did not create', async () => {
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
const feed = async (): Promise<Feed> =>
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)).json()) as Feed
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
// No room carries a `community` tag, and every seeded room belongs to Coach
// (account 1) — so the feed is empty until another account makes something.
expect(await feed()).toEqual({ Results: [], TotalResults: 0 })
const seeded: number[] = []
const seed = async (room: Record<string, unknown>) => {
seeded.push(Number(room.RoomId))
await seedRoomWithSubRooms(env.DB, {
Accessibility: 1,
IsDorm: false,
CreatorAccountId: 2,
...room,
})
}
await seed({ RoomId: 9101, Name: 'CommunityOne' })
await seed({ RoomId: 9102, Name: 'CommunityTwo' })
// Coach's own rooms stay out, and so do non-public rooms as everywhere else.
await seed({ RoomId: 9103, Name: 'CoachRoom', CreatorAccountId: 1 })
await seed({ RoomId: 9104, Name: 'UnlistedCommunityRoom', Accessibility: 2 })
// Nobody is in any of them and their stats are all zero, so the feed's normal
// ordering falls through to RoomId.
expect(await names()).toEqual(['CommunityOne', 'CommunityTwo'])
// Creator, not RRO-ness, is what `community` filters on — unlike `new`, a
// player-made room flagged as an RRO still belongs here.
await seed({ RoomId: 9105, Name: 'PlayerMadeRRO', IsRRO: true })
expect(await names()).toEqual(['CommunityOne', 'CommunityTwo', 'PlayerMadeRRO'])
// Paging comes off the same order.
const page = (await (
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=1&take=1`)
).json()) as Feed
expect(page).toMatchObject({ Results: [{ Name: 'CommunityTwo' }], TotalResults: 3 })
// Leave the shared feeds as they were for the tests that follow.
const ids = seeded.join(',')
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
})
it('GET /rooms/base returns a bare array of base/template rooms (incl. non-public)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/base`)
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{
RoomId: number
Accessibility: number
Tags?: Array<{ Tag: string }>
}>
expect(Array.isArray(body)).toBe(true)
expect(body.length).toBeGreaterThan(0)
// Every result carries the `base` tag.
expect(body.every((r) => (r.Tags ?? []).some((t) => t.Tag === 'base'))).toBe(true)
// Includes rooms that aren't publicly listed (Accessibility != 1) — base
// rooms bypass the public filter the feeds use.
expect(body.some((r) => r.Accessibility !== 1)).toBe(true)
})
it('GET /rooms/base respects take pagination', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/base?skip=0&take=5`)
const body = (await res.json()) as unknown[]
expect(body.length).toBeLessThanOrEqual(5)
})
it('GET /rooms/recommendations returns a bare array of public rooms (split-test params ignored)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/recommendations?splitTestId=1&splitTestValue=5`)
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ RoomId: number; IsDorm?: boolean }>
expect(Array.isArray(body)).toBe(true)
expect(body.length).toBeGreaterThan(0)
// The dorm (RoomId 1) is non-public, so it's never recommended.
expect(body.some((r) => r.RoomId === 1 || r.IsDorm === true)).toBe(false)
// The split-test params don't change the result.
const plain = (await (await SELF.fetch(`${ORIGIN}/rooms/recommendations`)).json()) as Array<{
RoomId: number
}>
expect(plain.map((r) => r.RoomId)).toEqual(body.map((r) => r.RoomId))
})
it('GET /rooms/recommendations respects take pagination', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/recommendations?skip=0&take=3`)
const body = (await res.json()) as unknown[]
expect(body.length).toBeLessThanOrEqual(3)
})
// The feeds narrow to the listable rooms in SQL (LISTABLE_WHERE, off idx_room_public)
// rather than by parsing every room in the database. That predicate has to answer
// exactly what the in-memory `isListable` answered, and the interesting case is the
// MISSING key: `ExcludeFromLists !== true` accepts a blob that never carried the field,
// so the SQL has to accept the NULL its generated column extracts.
it('the feeds’ SQL filter treats a missing ExcludeFromLists as not-excluded', async () => {
const seed = (data: Record<string, unknown>) =>
env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)).run()
// No `ExcludeFromLists` key at all — the shape of every room seeded before the flag
// existed.
await seed({
RoomId: 30801,
Name: 'NoExcludeKey',
CreatorAccountId: 830,
Accessibility: 1,
IsDorm: false,
SubRooms: [],
})
// Same room, opted out.
await seed({
RoomId: 30802,
Name: 'OptedOut',
CreatorAccountId: 830,
Accessibility: 1,
IsDorm: false,
ExcludeFromLists: true,
SubRooms: [],
})
const namesIn = async (path: string) => {
const body = (await (await SELF.fetch(`${ORIGIN}${path}`)).json()) as
{ Results: Array<{ Name: string }> } | Array<{ Name: string }>
return (Array.isArray(body) ? body : body.Results).map((r) => r.Name)
}
for (const feed of ['/rooms/hot?take=200', '/rooms/recommendations?take=200']) {
expect(await namesIn(feed)).toContain('NoExcludeKey')
expect(await namesIn(feed)).not.toContain('OptedOut')
}
// SEARCH is the wider filter (PUBLIC_WHERE): a room can opt out of the browse feeds
// and still be findable by name, so this must not have narrowed with the feeds.
expect(await namesIn('/rooms/search?query=optedout')).toContain('OptedOut')
await env.DB.prepare('DELETE FROM room WHERE room_id IN (30801, 30802)').run()
})
// Served only to the client builds that render it — the 2023 client's other room
// listings start failing with NREs when it gets this payload, which is why the route
// was parked entirely for a while (see FEATURED_ROOMS_VERSIONS in rooms.app.ts).
it('GET /featuredrooms/current serves the group to a supported client build', async () => {
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`, {
headers: await bearer('1', undefined, '20250718.01'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
FeaturedRoomGroupId: number
name: string
StartAt: string
EndAt: string
Rooms: Array<{ RoomId: number; RoomName: string; ImageName: string }>
}
expect(body.FeaturedRoomGroupId).toBe(1)
expect(body.name).toBe('Featured Rooms')
expect(body.Rooms.length).toBeGreaterThan(0)
// Compact projection carries name + image, not the full room blob.
expect(body.Rooms.every((r) => typeof r.RoomName === 'string')).toBe(true)
// The dorm (RoomId 1) is non-public, so it's never featured.
expect(body.Rooms.some((r) => r.RoomId === 1)).toBe(false)
})
it('GET /featuredrooms/current serves at most 10 rooms', async () => {
// Seed more eligible rooms than the cap, so the length is decided by the cap rather
// than by how many rooms the suite happens to have.
const ids = Array.from({ length: 14 }, (_, i) => 30900 + i)
for (const RoomId of ids) {
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId,
Name: `Featurable${RoomId}`,
CreatorAccountId: 831,
Accessibility: 1,
IsDorm: false,
SubRooms: [],
})
)
.run()
}
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`, {
headers: await bearer('1', undefined, '20250718.01'),
})
const body = (await res.json()) as { Rooms: Array<{ RoomId: number }> }
// A featured group is a short selection, not the whole room list.
expect(body.Rooms).toHaveLength(10)
// The cap TRIMS the shuffled list rather than sampling with replacement, so no room
// can appear twice in one group.
expect(new Set(body.Rooms.map((r) => r.RoomId)).size).toBe(10)
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids.join(', ')})`).run()
})
it('GET /featuredrooms/current withholds the group from other client builds', async () => {
// The 2023 build gets the 404 it got while the route was parked — the state in which
// its room listings work. Same for a token with no `rn.ver` at all.
for (const version of ['20230414', '20231207', undefined]) {
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`, {
headers: await bearer('1', undefined, version),
})
expect(res.status, `build ${version}`).toBe(404)
}
// And no token at all is a 401, not a 404: the build is read off the token.
expect((await SELF.fetch(`${ORIGIN}/featuredrooms/current`)).status).toBe(401)
})
it('GET /rooms/:id/similar returns { Results, TotalResults } of tag-sharing rooms (excluding self)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
Results: Array<{ RoomId: number; Tags?: Array<{ Tag: string }> }>
TotalResults: number
}
expect(body.Results.length).toBeGreaterThan(0)
expect(body.TotalResults).toBeGreaterThanOrEqual(body.Results.length)
// Never includes the target room itself.
expect(body.Results.some((r) => r.RoomId === 2)).toBe(false)
// Every result shares the `rro` tag RecCenter (room 2) carries.
expect(body.Results.every((r) => (r.Tags ?? []).some((t) => t.Tag === 'rro'))).toBe(true)
})
it('GET /rooms/:id/similar respects take pagination', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar?skip=0&take=3`)
const body = (await res.json()) as { Results: unknown[]; TotalResults: number }
expect(body.Results.length).toBeLessThanOrEqual(3)
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
})
it('GET /rooms/:id/similar returns an empty result for a room not in D1', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/99999/similar`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
it('POST /rooms/:id/clone clones a base room into a new owned room', async () => {
const headers = {
...(await bearer('801')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const post = async (id: number, name: string) =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/${id}/clone`, {
method: 'POST',
headers,
body: new URLSearchParams({ name }).toString(),
})
).json()) as {
success: boolean
error: string
value: {
RoomId: number
Name: string
CreatorAccountId: number
Tags?: Array<{ Tag: string }>
IsRRO: boolean
IsDeveloperOwned: boolean
CloningAllowed: boolean
Accessibility: number
Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }>
Stats: Record<string, number>
} | null
}
// Give the source every flag and counter the clone must NOT inherit.
await env.DB.prepare(
`UPDATE room SET data = json_set(data, '$.IsDeveloperOwned', json('true'),
'$.CloningAllowed', json('true'), '$.Stats.VisitorCount', 42) WHERE room_id = 24`
).run()
// Clone MakerRoom (base, RoomId 24) → a fresh room owned by the caller (801).
const ok = await post(24, 'MyMakerClone')
expect(ok.success).toBe(true)
expect(ok.error).toBe('')
expect(ok.value).not.toBeNull()
expect(ok.value!.Name).toBe('MyMakerClone')
expect(ok.value!.CreatorAccountId).toBe(801)
expect(ok.value!.RoomId).toBeGreaterThan(51)
// The clone starts fresh with no tags — none of the source's tags (including
// the `base` template tag) carry over.
expect(ok.value!.Tags).toEqual([])
// IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone.
expect(ok.value!.IsRRO).toBe(false)
// Nor is it developer-owned, and the new owner opts it into cloning themselves.
expect(ok.value!.IsDeveloperOwned).toBe(false)
expect(ok.value!.CloningAllowed).toBe(false)
// No engagement carries over — not even the blob-stored VisitorCount.
expect(ok.value!.Stats).toEqual({
CheerCount: 0,
FavoriteCount: 0,
VisitorCount: 0,
VisitCount: 0,
})
// A new room is unpublished: Private (0), never the source's visibility.
expect(ok.value!.Accessibility).toBe(0)
// Ownership is reset to the cloner: sole owner (Role 255), and none of the
// source base room's roles (accounts 1/2) carry over.
expect(ok.value!.Roles).toEqual([
{ AccountId: 801, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
])
// It persists and is fetchable by its new id.
const fetched = (await (await SELF.fetch(`${ORIGIN}/rooms/${ok.value!.RoomId}`)).json()) as {
Name: string
Tags: unknown[]
IsDeveloperOwned: boolean
CloningAllowed: boolean
Stats: Record<string, number>
}
expect(fetched.Name).toBe('MyMakerClone')
expect(fetched.Tags).toEqual([])
expect(fetched.IsDeveloperOwned).toBe(false)
expect(fetched.CloningAllowed).toBe(false)
expect(fetched.Stats.VisitorCount).toBe(0)
// Duplicate name is rejected.
const dup = await post(24, 'MyMakerClone')
expect(dup).toMatchObject({ success: false, value: null })
expect(dup.error).toMatch(/already exists/i)
})
it('POST /rooms/:id/clone of a PUBLIC source stays out of the public feeds', async () => {
// Park (RoomId 25) is the one seeded base room that is itself public
// (Accessibility 1). Cloning used to inherit that, so a room appeared in
// hot/search/recommendations the instant it was created — before its owner had
// published anything.
const res = await SELF.fetch(`${ORIGIN}/rooms/25/clone`, {
method: 'POST',
headers: {
...(await bearer('802')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ name: 'ParkCloneUnpublished' }).toString(),
})
const { value } = (await res.json()) as { value: { RoomId: number; Accessibility: number } }
expect(value.Accessibility).toBe(0)
const namesIn = async (path: string) => {
const body = (await (await SELF.fetch(`${ORIGIN}${path}`)).json()) as
{ Results: Array<{ Name: string }> } | Array<{ Name: string }>
return (Array.isArray(body) ? body : body.Results).map((r) => r.Name)
}
expect(await namesIn('/rooms/hot?take=200')).not.toContain('ParkCloneUnpublished')
expect(await namesIn('/rooms/hot?tag=new&take=200')).not.toContain('ParkCloneUnpublished')
expect(await namesIn('/rooms/recommendations?take=200')).not.toContain('ParkCloneUnpublished')
expect(await namesIn('/rooms/search?query=parkcloneunpublished')).not.toContain(
'ParkCloneUnpublished'
)
// Publishing it (owner sets Accessibility to Public) puts it in the feed.
await putForm('/rooms/' + value.RoomId + '/accessibility', { accessibility: '1' }, '802')
expect(await namesIn('/rooms/hot?take=200')).toContain('ParkCloneUnpublished')
})
it('POST /rooms/:id/clone requires auth (401, no account-1 fallback)', async () => {
// No Authorization header → hard 401, and nothing is created.
const res = await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name: 'UnauthedClone' }).toString(),
})
expect(res.status).toBe(401)
expect(await res.json()).toMatchObject({ success: false, value: null })
// An invalid/garbage token is also rejected.
const bad = await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
method: 'POST',
headers: {
Authorization: 'Bearer not.a.jwt',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ name: 'UnauthedClone' }).toString(),
})
expect(bad.status).toBe(401)
// The room was never created.
const lookup = await SELF.fetch(`${ORIGIN}/rooms?name=UnauthedClone`)
expect(await lookup.json()).toEqual({})
})
it('POST /rooms/:id/clone validates name and cloneability', async () => {
const headers = {
...(await bearer('802')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const post = async (id: number, body?: string) =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/${id}/clone`, { method: 'POST', headers, body })
).json()) as { success: boolean; error: string; value: unknown }
// Missing name.
const noName = await post(24, new URLSearchParams({ name: '' }).toString())
expect(noName).toMatchObject({ success: false, value: null })
expect(noName.error).toMatch(/must enter a name/i)
// The dorm (RoomId 1) disallows cloning.
const notCloneable = await post(1, new URLSearchParams({ name: 'CannotCloneDorm' }).toString())
expect(notCloneable).toMatchObject({ success: false, value: null })
expect(notCloneable.error).toMatch(/can't clone/i)
// A source room not in D1.
const missing = await post(99999, new URLSearchParams({ name: 'CloneOfNothing' }).toString())
expect(missing).toMatchObject({ success: false, value: null })
})
it('POST /rooms/:id/clone enforces the per-account room cap', async () => {
const headers = {
...(await bearer('803')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const clone = async (name: string) =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
method: 'POST',
headers,
body: new URLSearchParams({ name }).toString(),
})
).json()) as { success: boolean; error: string; value: unknown }
// The cap an operator actually runs is the `MAX_ROOMS_PER_ACCOUNT` var; the
// constant in the worker is only the fallback.
const original = env.MAX_ROOMS_PER_ACCOUNT
try {
env.MAX_ROOMS_PER_ACCOUNT = 2
expect(await clone('CapOne')).toMatchObject({ success: true })
expect(await clone('CapTwo')).toMatchObject({ success: true })
const rejected = await clone('CapThree')
expect(rejected).toMatchObject({ success: false, value: null })
expect(rejected.error).toMatch(/only have 2 rooms/i)
// A dorm doesn't count against the cap — it's auto-provisioned, not made.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 30303,
Name: '^Dorm803',
CreatorAccountId: 803,
IsDorm: true,
SubRooms: [],
Roles: [],
})
)
.run()
env.MAX_ROOMS_PER_ACCOUNT = 3
expect(await clone('CapThreeForReal')).toMatchObject({ success: true })
// 0 lifts the cap entirely.
env.MAX_ROOMS_PER_ACCOUNT = 0
expect(await clone('Uncapped')).toMatchObject({ success: true })
} finally {
env.MAX_ROOMS_PER_ACCOUNT = original
}
})
const postForm = async (
path: string,
fields: Record<string, string>,
sub?: string,
roles?: string[]
) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(sub ? await bearer(sub, roles) : {}),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
const putForm = async (path: string, fields: Record<string, string>, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: {
...(sub ? await bearer(sub) : {}),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
// Room-mutation envelope helper (PascalCase `{ Success, Value, ErrorId, Error }` —
// used by name/image/description).
type RoomResult = {
Success: boolean
Value: unknown
ErrorId: string | null
Error: string | null
}
const bodyOf = async (res: Response) => (await res.json()) as RoomResult
// Lowercase `{ success, error, value }` envelope — used by tags/clone and the
// roles/warning/cloning/restrictions room-settings mutations (value = updated room).
type RoomEnv = { success: boolean; error: string; value: Record<string, unknown> | null }
const envOf = async (res: Response) => (await res.json()) as RoomEnv
// A subroom as the client sees it. There is no GET for a single subroom — the client
// reads them off the room — so tests do the same.
const subRoomOf = async (
roomId: number,
subRoomId: number
): Promise<Record<string, unknown> | undefined> => {
const res = await SELF.fetch(`${ORIGIN}/rooms/${roomId}`)
if (res.status !== 200) return undefined
const room = (await res.json()) as { SubRooms?: Array<Record<string, unknown>> }
return (room.SubRooms ?? []).find((s) => s.SubRoomId === subRoomId)
}
it('PUT /rooms/:id/description is auth-gated, owner-only, and persists', async () => {
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/description', { description: 'x' })).status).toBe(401)
// Not the owner (RecCenter is owned by account 1) → 200 envelope, Success:false.
expect(
await bodyOf(await putForm('/rooms/2/description', { description: 'x' }, '999'))
).toMatchObject({ Success: false, ErrorId: 'Rooms.NotOwner' })
// Unknown room → Rooms.DoesntExist envelope.
expect(
await bodyOf(await putForm('/rooms/99999/description', { description: 'x' }, '1'))
).toMatchObject({
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
// Owner updates it, and it persists.
const ok = await putForm('/rooms/2/description', { description: 'blah blah blah' }, '1')
expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({
Success: true,
Value: null,
ErrorId: null,
Error: null,
})
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Description: string }
expect(room.Description).toBe('blah blah blah')
})
it('PUT /rooms/:id/image is auth-gated, owner-only, and persists', async () => {
const imageName = '644064b03bd64a8291cde284629e9ca9.jpg'
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/image', { imageName })).status).toBe(401)
// Not the owner (RecCenter is owned by account 1) → 200 envelope, Success:false.
expect(await bodyOf(await putForm('/rooms/2/image', { imageName }, '999'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
// Unknown room → Rooms.DoesntExist envelope.
expect(await bodyOf(await putForm('/rooms/99999/image', { imageName }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.DoesntExist',
})
// Empty image → Success:false.
expect(await bodyOf(await putForm('/rooms/2/image', { imageName: ' ' }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.InvalidImage',
})
// Owner sets it, and it persists.
const ok = await putForm('/rooms/2/image', { imageName }, '1')
expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({ Success: true })
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { ImageName: string }
expect(room.ImageName).toBe(imageName)
})
it('DELETE /rooms/:id is auth-gated, owner-only, and removes the room + its CDN image', async () => {
// Throwaway room owned by account 1, with its image object in the CDN bucket and
// a player interaction row.
const ImageName = 'test/2026-07-17/delete-me.jpg'
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 9500,
Name: 'DeleteMe',
CreatorAccountId: 1,
IsDorm: false,
Accessibility: 1,
ImageName,
SubRooms: [],
})
)
.run()
await env.CDN_ASSETS.put(`room/${ImageName}`, new Uint8Array([1, 2, 3]))
await env.DB.prepare(
'INSERT INTO interaction (player_id, room_id, cheered, favorited) VALUES (7, 9500, 1, 1)'
).run()
const del = async (sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/9500`, {
method: 'DELETE',
headers: sub ? await bearer(sub) : {},
})
const roomExists = async () =>
(await env.DB.prepare('SELECT 1 FROM room WHERE room_id = 9500').first()) !== null
// No token → 401. A non-owner → Success:false (room untouched).
expect((await del()).status).toBe(401)
expect(await bodyOf(await del('2'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
expect(await roomExists()).toBe(true)
// Owner → Success:true; the room, its interactions, and the CDN image are gone.
expect(await bodyOf(await del('1'))).toMatchObject({ Success: true })
expect(await roomExists()).toBe(false)
expect(await env.CDN_ASSETS.get(`room/${ImageName}`)).toBeNull()
const interactions = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM interaction WHERE room_id = 9500'
).first<{ n: number }>()
expect(interactions!.n).toBe(0)
})
it('DELETE /rooms/:id also lets staff through — the website’s takedown — but only there', async () => {
// Somebody else's room. A moderator token may delete it; the same token may NOT
// rename it, which is what a misapplied check once allowed.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 9501,
Name: 'TakeMeDown',
CreatorAccountId: 1,
IsDorm: false,
Accessibility: 1,
ImageName: '',
SubRooms: [],
})
)
.run()
const staff = await bearer('999', ['gameClient', 'moderator'])
const roomExists = async () =>
(await env.DB.prepare('SELECT 1 FROM room WHERE room_id = 9501').first()) !== null
const rename = await SELF.fetch(`${ORIGIN}/rooms/9501/name`, {
method: 'PUT',
headers: { ...staff, 'content-type': 'application/x-www-form-urlencoded' },
body: 'name=Renamed',
})
expect(await bodyOf(rename)).toMatchObject({ Success: false, ErrorId: 'Rooms.NotOwner' })
// A plain player is still refused; the moderator gets through.
const del = async (headers: Record<string, string>) =>
bodyOf(await SELF.fetch(`${ORIGIN}/rooms/9501`, { method: 'DELETE', headers }))
expect(await del(await bearer('2', ['gameClient']))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
expect(await roomExists()).toBe(true)
expect(await del(staff)).toMatchObject({ Success: true })
expect(await roomExists()).toBe(false)
// The takedown is the one row in the audit log: the owner's own deletion in the test
// above wrote none, and the refused attempts wrote none. It names the actor and keeps
// enough of the room to say which one it was, now that the room itself is gone.
const { results: audit } = await env.DB.prepare(
'SELECT player_id, action, data FROM audit_log WHERE action = ?1'
)
.bind('room_delete')
.all<{ player_id: number; action: string; data: string }>()
expect(audit).toHaveLength(1)
expect(audit[0].player_id).toBe(999)
expect(JSON.parse(audit[0].data)).toEqual({
roomId: 9501,
name: 'TakeMeDown',
creatorAccountId: 1,
accessibility: 1,
})
})
it('PUT /rooms/:id/roles/:accountId grants a helper role outright, but never co-owner', async () => {
type Role = {
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
// No token → 401; a valid token with no standing in the room → 403.
expect((await putForm('/rooms/2/roles/860', { role: '20' })).status).toBe(401)
expect((await putForm('/rooms/2/roles/860', { role: '20' }, '999')).status).toBe(403)
// The owner grants Moderator, which takes effect immediately — no invite, no accept.
const ok = await putForm('/rooms/2/roles/860', { role: '20' }, '1')
expect(ok.status).toBe(200)
expect(await envOf(ok)).toMatchObject({ success: true, error: '' })
expect(await roleOf(860)).toMatchObject({
Role: 20,
InvitedRole: 0,
LastChangedByAccountId: 1,
})
// A co-owner may hand out the helper tiers too, updating the entry in place rather
// than adding a second.
expect((await putForm('/rooms/2/roles/860', { role: '10' }, '2')).status).toBe(200)
const roles = (await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]
expect(roles.filter((r) => r.AccountId === 860)).toHaveLength(1)
expect(await roleOf(860)).toMatchObject({ Role: 10, LastChangedByAccountId: 2 })
// The two tiers a grant may never reach — co-ownership has to be accepted, and
// Creator is more than co-ownership. Neither writes anything.
for (const role of ['30', '255']) {
expect(await envOf(await putForm('/rooms/2/roles/860', { role }, '1'))).toMatchObject({
success: false,
error: 'Co-ownership must be invited, not granted!',
})
}
// A grant leaves a pending co-owner invite standing — the two are answered separately.
expect((await putForm('/rooms/2/roles/861/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/861', { role: '20' }, '1')).status).toBe(200)
expect(await roleOf(861)).toMatchObject({ Role: 20, InvitedRole: 30 })
})
it('PUT /rooms/:id/roles/:accountId with role=0 from an owner REVOKES the role', async () => {
type Role = { AccountId: number; Role: number; InvitedRole: number }
const rolesOf = async (): Promise<Role[]> =>
(await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]
const roleOf = async (accountId: number) =>
(await rolesOf()).find((r) => r.AccountId === accountId)
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
expect((await putForm('/rooms/2/roles/880', { role: '10' }, '1')).status).toBe(200)
expect(await roleOf(880)).toMatchObject({ Role: 10 })
// The owner removes the role. The RECORD goes — there is no "holds no role" tier — and
// the envelope carries the whole updated room, which is what the client re-renders from.
const revoked = await putForm('/rooms/2/roles/880', { role: '0' }, '1')
expect(revoked.status).toBe(200)
const body = (await envOf(revoked)) as {
success: boolean
error: string
value: { Roles: Role[] }
}
expect(body).toMatchObject({ success: true, error: '' })
expect(body.value.Roles.some((r) => r.AccountId === 880)).toBe(false)
expect(await roleOf(880)).toBeUndefined()
// A body naming NO role is the same removal.
expect((await putForm('/rooms/2/roles/881', { role: '20' }, '1')).status).toBe(200)
expect(await envOf(await putForm('/rooms/2/roles/881', {}, '1'))).toMatchObject({
success: true,
})
expect(await roleOf(881)).toBeUndefined()
// ...but a role that is PRESENT and unparseable is still malformed, and must not be read
// as a removal — a typo'd tier silently stripping someone would be the worst outcome.
expect((await putForm('/rooms/2/roles/881', { role: '20' }, '1')).status).toBe(200)
expect(await envOf(await putForm('/rooms/2/roles/881', { role: 'nope' }, '1'))).toMatchObject({
success: false,
error: 'You must provide a valid role!',
})
expect(await roleOf(881)).toMatchObject({ Role: 20 })
// A co-owner may revoke a helper tier.
expect(await envOf(await putForm('/rooms/2/roles/881', { role: '0' }, '2'))).toMatchObject({
success: true,
})
expect(await roleOf(881)).toBeUndefined()
// Removing a role the player does not hold is not a failure: they asked for no role, and
// there is none. Nothing changes.
const before = await rolesOf()
expect(await envOf(await putForm('/rooms/2/roles/889', { role: '0' }, '1'))).toMatchObject({
success: true,
})
expect(await rolesOf()).toEqual(before)
// The gate still applies: no token 401s, and no standing in the room 403s.
expect((await putForm('/rooms/2/roles/882', { role: '0' })).status).toBe(401)
expect((await putForm('/rooms/2/roles/882', { role: '0' }, '999')).status).toBe(403)
})
it('a role revoke cannot touch the owner, and co-owners cannot revoke each other', async () => {
type Role = { AccountId: number; Role: number; InvitedRole: number }
const roleOf = async (accountId: number) =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
const refused = { success: false, error: 'You cannot remove that role!' }
// Ownership is not a role anyone beside it can take away — it moves only by transfer.
// Account 1 is RecCenter's creator; its co-owner cannot remove it.
expect(await envOf(await putForm('/rooms/2/roles/1', { role: '0' }, '2'))).toMatchObject(
refused
)
// Make 883 a second co-owner the only legal way: invited, then accepted.
expect((await putForm('/rooms/2/roles/883/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/883', { role: '30' }, '883')).status).toBe(200)
expect(await roleOf(883)).toMatchObject({ Role: 30 })
// Co-owners are peers, so one cannot strip the other — whichever asked first would win.
expect(await envOf(await putForm('/rooms/2/roles/883', { role: '0' }, '2'))).toMatchObject(
refused
)
expect(await envOf(await putForm('/rooms/2/roles/2', { role: '0' }, '883'))).toMatchObject(
refused
)
expect(await roleOf(883)).toMatchObject({ Role: 30 })
// The room's owner is not their peer, and may.
expect(await envOf(await putForm('/rooms/2/roles/883', { role: '0' }, '1'))).toMatchObject({
success: true,
})
expect(await roleOf(883)).toBeUndefined()
})
it('a role revoke takes a standing invite with it, and never removes your own entry', async () => {
type Role = { AccountId: number; Role: number; InvitedRole: number }
const roleOf = async (accountId: number) =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// The whole entry goes, so a pending co-owner invite on it is withdrawn too.
expect((await putForm('/rooms/2/roles/884/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/884', { role: '10' }, '1')).status).toBe(200)
expect(await roleOf(884)).toMatchObject({ Role: 10, InvitedRole: 30 })
expect((await putForm('/rooms/2/roles/884', { role: '0' }, '1')).status).toBe(200)
expect(await roleOf(884)).toBeUndefined()
// On your OWN entry the path means answering an invite, which has to name its answer. An
// empty body is a removal, and removing yourself is not what that branch does.
expect((await putForm('/rooms/2/roles/885/invite', { role: '30' }, '1')).status).toBe(200)
expect(await envOf(await putForm('/rooms/2/roles/885', {}, '885'))).toMatchObject({
success: false,
error: 'You must provide a valid role!',
})
expect(await roleOf(885)).toMatchObject({ InvitedRole: 30 })
})
it('a revoke pushes a RoomUpdate to the room and to the player who lost the role', async () => {
type Sent = { playerId: number; notificationType: string | number; data: { RoomId: number } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
expect((await putForm('/rooms/4/roles/887', { role: '20' }, '1')).status).toBe(200)
// A bystander in the room, and the player losing the role standing somewhere else.
await putInRoom(886, 4)
await putInRoom(887, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/4/roles/887', { role: '0' }, '1')).status).toBe(200)
const pushed = (await (await hub().fetch('http://do/all')).json()) as Sent[]
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([886, 887])
expect(pushed.every((n) => n.data.RoomId === 4)).toBe(true)
for (const id of [886, 887]) await clearPresence(id)
})
it('a grant pushes a RoomUpdate to the room and to the affected player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: { RoomId: number } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// One bystander in room 4, and the player being granted the role standing somewhere
// else entirely — a role they can use when they arrive, so they hear about it too.
await putInRoom(870, 4)
await putInRoom(871, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/4/roles/871', { role: '20' }, '1')).status).toBe(200)
const pushed = await sent()
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([870, 871])
expect(
pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom)
).toBe(true)
expect(pushed.every((n) => n.data.RoomId === 4)).toBe(true)
for (const id of [870, 871]) await clearPresence(id)
})
it('PUT /rooms/:id/roles/:accountId accepts a standing invite, and only that one', async () => {
type Role = {
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership,
// leaving a pending offer and no role yet.
expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200)
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401)
// Nobody can accept on 850's behalf. A stranger has no standing here at all (403);
// the owner and the co-owner fall through to the GRANT path, where co-ownership is
// exactly the tier they may not hand over.
expect((await putForm('/rooms/2/roles/850', { role: '30' }, '999')).status).toBe(403)
for (const caller of ['1', '2']) {
expect(
await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, caller))
).toMatchObject({ success: false, error: 'Co-ownership must be invited, not granted!' })
}
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// Unknown room → failure envelope.
expect(
await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
// Non-numeric role → failure envelope.
expect(await envOf(await putForm('/rooms/2/roles/850', { role: 'nope' }, '850'))).toMatchObject(
{
success: false,
}
)
// Asking for a HIGHER role than the one offered is the attack this guards against:
// refused, and the entry is untouched — still no role, invite still standing.
expect(await envOf(await putForm('/rooms/2/roles/850', { role: '255' }, '850'))).toMatchObject({
success: false,
})
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// A player with no entry in the room at all is the same rejection — probing tells
// them nothing.
expect(await envOf(await putForm('/rooms/2/roles/833', { role: '30' }, '833'))).toMatchObject({
success: false,
})
expect(await roleOf(833)).toBeUndefined()
// Accepting the role actually offered promotes the entry and consumes the invite.
const ok = await putForm('/rooms/2/roles/850', { role: '30' }, '850')
expect(ok.status).toBe(200)
const okBody = await envOf(ok)
expect(okBody).toMatchObject({ success: true, error: '' })
expect(okBody.value?.Roles as Role[]).toContainEqual(
expect.objectContaining({ AccountId: 850, Role: 30, InvitedRole: 0 })
)
expect(await roleOf(850)).toMatchObject({
Role: 30,
InvitedRole: 0,
// Left as the OWNER who offered the role, not restamped with the accepter.
LastChangedByAccountId: 1,
})
// The invite is spent: the same call again is refused, so it can't be replayed.
expect(await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, '850'))).toMatchObject({
success: false,
})
expect(await roleOf(850)).toMatchObject({ Role: 30, InvitedRole: 0 })
})
it('PUT /rooms/:id/roles/:accountId with role=0 declines, dropping the entry', async () => {
type Role = { AccountId: number; Role: number; InvitedRole: number }
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// Declining with nothing standing is the same rejection as any other unanswerable
// invite — it can't be used to drop a role nobody offered to change.
expect(await envOf(await putForm('/rooms/2/roles/851', { role: '0' }, '851'))).toMatchObject({
success: false,
})
// Invited, then declined: the whole entry goes, leaving `Roles` as it was before the
// offer rather than an entry with nothing pending on it.
expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200)
expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 })
const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851')
expect(declined.status).toBe(200)
expect(await envOf(declined)).toMatchObject({ success: true, error: '' })
expect(await roleOf(851)).toBeUndefined()
// And the decline can't be replayed either — there is no entry left to answer.
expect(await envOf(await putForm('/rooms/2/roles/851', { role: '0' }, '851'))).toMatchObject({
success: false,
})
// Declining takes a role the player ALREADY held with it: the invite is answered as a
// whole, so a re-invited member who declines is left with nothing.
expect((await putForm('/rooms/2/roles/852/invite', { role: '10' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '10' }, '852')).status).toBe(200)
expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 })
expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200)
expect(await roleOf(852)).toBeUndefined()
})
it('accepting an invite pushes a RoomUpdate to everyone in the room', async () => {
type Sent = {
playerId: number
notificationType: string | number
data: { RoomId: number; Roles: Array<{ AccountId: number; Role: number }> }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// Room 3's owner invites 840, who is standing in the room with two others. A fourth
// player is in a DIFFERENT room, and a fifth's presence has lapsed — neither is there.
expect((await putForm('/rooms/3/roles/840/invite', { role: '20' }, '1')).status).toBe(200)
await putInRoom(840, 3)
await putInRoom(841, 3)
await putInRoom(842, 2)
await putInRoom(843, 3, { expired: true })
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/3/roles/840', { role: '20' }, '840')).status).toBe(200)
// A role change alters what the room lets people do, so everyone standing in it
// re-renders — including the accepter, who is one of them.
const pushed = await sent()
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([840, 841])
expect(
pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom)
).toBe(true)
// Each carries the room as it now stands, with the accepted role on it.
expect(pushed[0].data).toMatchObject({ RoomId: 3 })
expect(pushed[0].data.Roles).toContainEqual(
expect.objectContaining({ AccountId: 840, Role: 20, InvitedRole: 0 })
)
for (const id of [840, 841, 842, 843]) await clearPresence(id)
})
it('GET /rooms/:id/roles serves the room’s roles as a bare array', async () => {
// RecCenter (room 2) is seeded with its creator (255) and a co-owner (30). Public,
// like the room itself — no token.
const res = await SELF.fetch(`${ORIGIN}/rooms/2/roles`)
expect(res.status).toBe(200)
// Containment, not equality: the role-write test above grants account 5 a role on
// this same room, and the two tests share a database.
expect(await res.json()).toEqual(
expect.arrayContaining([
{ AccountId: 1, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 2, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
])
)
// An unknown room reads the same as a room nobody holds a role in.
expect((await SELF.fetch(`${ORIGIN}/rooms/99999/roles`)).status).toBe(200)
expect(await (await SELF.fetch(`${ORIGIN}/rooms/99999/roles`)).json()).toEqual([])
// A role written without `LastChangedByAccountId`/`InvitedRole` (the older rooms) is
// still served as a whole record — the client's parser wants every key.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 30601,
Name: 'PartialRoles',
CreatorAccountId: 830,
SubRooms: [],
Roles: [{ AccountId: 830, Role: 255 }],
})
)
.run()
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30601/roles`)).json()).toEqual([
{ AccountId: 830, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
])
// A room with no `Roles` key at all is an empty list, not a failure.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(JSON.stringify({ RoomId: 30602, Name: 'NoRoles', CreatorAccountId: 830, SubRooms: [] }))
.run()
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30602/roles`)).json()).toEqual([])
})
it('PUT /rooms/:id/creator hands the room over, and only the owner may', async () => {
type Role = { AccountId: number; Role: number; LastChangedByAccountId: number | null }
const roomOf = async (roomId: number) =>
(await (await SELF.fetch(`${ORIGIN}/rooms/${roomId}`)).json()) as {
CreatorAccountId: number
Roles: Role[]
}
// A room of account 1's to give away, and two accounts to try it with.
await seedRoomWithSubRooms(env.DB, {
RoomId: 30701,
Name: 'HandMeDown',
CreatorAccountId: 1,
Accessibility: 1,
SubRooms: [],
Roles: [{ AccountId: 1, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }],
})
for (const accountId of [880, 881]) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId, username: `Taker${accountId}` }))
.run()
}
// No token → 401; a valid token that doesn't own the room → 403.
expect((await putForm('/rooms/30701/creator', { accountId: '880' })).status).toBe(401)
expect((await putForm('/rooms/30701/creator', { accountId: '880' }, '999')).status).toBe(403)
// Unknown room → failure envelope.
expect(
await envOf(await putForm('/rooms/99999/creator', { accountId: '880' }, '1'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
// No account, yourself, and a player who doesn't exist are each refused — a room
// handed to a nonexistent account would be orphaned for good.
expect(await envOf(await putForm('/rooms/30701/creator', {}, '1'))).toMatchObject({
success: false,
})
expect(
await envOf(await putForm('/rooms/30701/creator', { accountId: '1' }, '1'))
).toMatchObject({ success: false, error: 'You already own this room!' })
expect(
await envOf(await putForm('/rooms/30701/creator', { accountId: '99998' }, '1'))
).toMatchObject({ success: false, error: 'That player does not exist!' })
expect((await roomOf(30701)).CreatorAccountId).toBe(1)
// The owner hands it to 880: they become Creator, the outgoing owner is left CoOwner,
// and `CreatorAccountId` moves with them.
const ok = await putForm('/rooms/30701/creator', { accountId: '880' }, '1')
expect(ok.status).toBe(200)
expect(await envOf(ok)).toMatchObject({ success: true, error: '' })
const after = await roomOf(30701)
expect(after.CreatorAccountId).toBe(880)
expect(after.Roles).toContainEqual(
expect.objectContaining({ AccountId: 880, Role: 255, LastChangedByAccountId: 1 })
)
expect(after.Roles).toContainEqual(
expect.objectContaining({ AccountId: 1, Role: 30, LastChangedByAccountId: 1 })
)
// The old owner is a co-owner now, not the owner: they can no longer give the room
// away — otherwise the transfer would have left it with two owners.
expect((await putForm('/rooms/30701/creator', { accountId: '881' }, '1')).status).toBe(403)
// The new owner can, and the room only ever has one Creator entry.
expect((await putForm('/rooms/30701/creator', { accountId: '881' }, '880')).status).toBe(200)
const handedOn = await roomOf(30701)
expect(handedOn.CreatorAccountId).toBe(881)
expect(handedOn.Roles.filter((r) => r.Role === 255)).toEqual([
expect.objectContaining({ AccountId: 881 }),
])
expect(handedOn.Roles).toContainEqual(expect.objectContaining({ AccountId: 880, Role: 30 }))
})
it('PUT /rooms/:id/creator refuses a dorm, and pushes the room on a transfer', async () => {
type Sent = { playerId: number; notificationType: string | number; data: { RoomId: number } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// A dorm is found by its owner, so handing one over would give away somebody's
// personal room and mint them a fresh empty one on next access.
await seedRoomWithSubRooms(env.DB, {
RoomId: 30702,
Name: 'SomeonesDorm',
CreatorAccountId: 890,
IsDorm: true,
Accessibility: 2,
SubRooms: [],
Roles: [{ AccountId: 890, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }],
})
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 891, username: 'NotYourDorm' }))
.run()
expect(
await envOf(await putForm('/rooms/30702/creator', { accountId: '891' }, '890'))
).toMatchObject({ success: false, error: 'A dorm cannot be given away!' })
const dorm = (await (await SELF.fetch(`${ORIGIN}/rooms/30702`)).json()) as {
CreatorAccountId: number
}
expect(dorm.CreatorAccountId).toBe(890)
// A real transfer reaches everyone in the room plus both parties, wherever they are.
await seedRoomWithSubRooms(env.DB, {
RoomId: 30703,
Name: 'WatchedHandover',
CreatorAccountId: 892,
Accessibility: 1,
SubRooms: [],
Roles: [{ AccountId: 892, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }],
})
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 893, username: 'NewOwner' }))
.run()
await putInRoom(894, 30703)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/30703/creator', { accountId: '893' }, '892')).status).toBe(200)
const pushed = await sent()
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([892, 893, 894])
expect(
pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom)
).toBe(true)
expect(pushed.every((n) => n.data.RoomId === 30703)).toBe(true)
await clearPresence(894)
})
it('PUT /rooms/:id/roles/:accountId/invite records the offer and pushes the invite', async () => {
type Sent = {
playerId: number
notificationType: string | number
data: Record<string, unknown>
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const rolesOf = async (roomId: number) =>
(await (await SELF.fetch(`${ORIGIN}/rooms/${roomId}/roles`)).json()) as Array<{
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}>
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' })).status).toBe(401)
// A valid token with no standing on the room → 403...
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' }, '999')).status).toBe(403)
// ...and so does the CO-OWNER (account 2, Role 30), who may set roles outright but
// may not hand out co-ownership. This is the gate that is narrower than the write.
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' }, '2')).status).toBe(403)
// An unknown room answers the same 403 rather than leaking that it doesn't exist.
expect((await putForm('/rooms/99999/roles/187/invite', { role: '30' }, '1')).status).toBe(403)
// A non-numeric role is a bad request — there is no envelope here to carry a message.
expect((await putForm('/rooms/2/roles/187/invite', { role: 'nope' }, '1')).status).toBe(400)
// None of the refusals wrote a role entry.
expect((await rolesOf(2)).find((r) => r.AccountId === 187)).toBeUndefined()
// The owner invites 187 to co-ownership.
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await putForm('/rooms/2/roles/187/invite', { role: '30' }, '1')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// A new entry, holding no role yet — only the pending offer, stamped with the inviter.
expect(await rolesOf(2)).toContainEqual({
AccountId: 187,
Role: 0,
InvitedRole: 30,
LastChangedByAccountId: 1,
})
// One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited —
// the entry alone is silent, this is what raises the invite on their client.
const pushed = await sent()
expect(pushed).toHaveLength(1)
expect(pushed[0].playerId).toBe(187)
expect(pushed[0].notificationType).toBe(NotificationType.MessageReceived)
expect(pushed[0].data).toMatchObject({
FromPlayerId: 1,
Type: MessageType.RoomCoOwnerInvited,
// The offered role, as a string — what the client reads the tier off, and sends
// back as `role` to accept.
Data: '30',
RoomId: 2,
PlayerEventId: null,
})
// The invite is STORED before it is pushed, and the frame carries the row's id and
// timestamp — so the invited player reads the same invite from their inbox
// (`GET /api/messages/v2/get`) even if the push never reached them.
const row = await env.DB.prepare(
'SELECT notification_id, from_player_id, type, data, room_id, sent_time FROM notification WHERE to_player_id = 187'
).first<{
notification_id: number
from_player_id: number
type: number
data: string | null
room_id: number
sent_time: string
}>()
expect(row).toMatchObject({
from_player_id: 1,
type: MessageType.RoomCoOwnerInvited,
data: '30',
room_id: 2,
})
expect(pushed[0].data.Id).toBe(row!.notification_id)
expect(pushed[0].data.SentTime).toBe(row!.sent_time)
expect(Date.parse(row!.sent_time)).not.toBeNaN()
// Re-inviting someone who already has an entry sets the pending role in place rather
// than adding a second entry — and leaves the role they already hold alone. 187
// accepts the invite above first, so they hold a real role to be re-invited over.
expect((await putForm('/rooms/2/roles/187', { role: '30' }, '187')).status).toBe(200)
await putForm('/rooms/2/roles/187/invite', { role: '20' }, '1')
const after = await rolesOf(2)
expect(after.filter((r) => r.AccountId === 187)).toHaveLength(1)
expect(after).toContainEqual({
AccountId: 187,
Role: 30,
InvitedRole: 20,
LastChangedByAccountId: 1,
})
})
it('POST /rooms/:id/bans is gated to the room’s owners or staff, and persists', async () => {
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
const bansOf = async (roomId: number) =>
(
await env.DB.prepare(
'SELECT banned_player_id, ban_mask, banned_by_account_id FROM room_ban WHERE room_id = ?1'
)
.bind(roomId)
.all<{ banned_player_id: number; ban_mask: number; banned_by_account_id: number }>()
).results
// No token → 401 (auth gate).
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' })).status).toBe(401)
// A valid token, no role on the room and no staff role → 403.
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '999')).status).toBe(403)
// Unknown room → failure envelope.
expect(
await envOf(await postForm('/rooms/99999/bans', { banMask: '0', id: '205' }, '1'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
// The owner bans player 205 — the real client body.
const ok = await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '1')
expect(ok.status).toBe(200)
expect(await envOf(ok)).toMatchObject({
success: true,
error: '',
value: { RoomId: 2, BannedPlayerId: 205, BanMask: 0, BannedByAccountId: 1 },
})
expect(await bansOf(2)).toEqual([
{ banned_player_id: 205, ban_mask: 0, banned_by_account_id: 1 },
])
// Re-banning rewrites the one row rather than appending a second.
expect((await postForm('/rooms/2/bans', { banMask: '7', id: '205' }, '2')).status).toBe(200)
expect(await bansOf(2)).toEqual([
{ banned_player_id: 205, ban_mask: 7, banned_by_account_id: 2 },
])
// A staff token bans in a room they have no role on.
const byStaff = await postForm('/rooms/2/bans', { id: '206' }, '999', [
'gameClient',
'moderator',
])
expect(byStaff.status).toBe(200)
// banMask defaults to 0 when the field is absent.
expect(await envOf(byStaff)).toMatchObject({ value: { BannedPlayerId: 206, BanMask: 0 } })
// Refusals: no id, yourself, and an owner of the room (a co-owner must not be
// able to ban the creator out of their own room).
expect(await envOf(await postForm('/rooms/2/bans', { id: 'nope' }, '1'))).toMatchObject({
success: false,
value: null,
})
expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '1'))).toMatchObject({
success: false,
error: 'You cannot ban yourself!',
})
expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '2'))).toMatchObject({
success: false,
error: 'You cannot ban an owner of this room!',
})
// Nothing was written by any of the refusals.
expect(await bansOf(2)).toHaveLength(2)
})
it('GET /Room_server/rooms/:id/bans/:playerId/isBanned answers the real ban state', async () => {
const isBanned = async (roomId: number, playerId: number, sub = '300') =>
SELF.fetch(`${ORIGIN}/Room_server/rooms/${roomId}/bans/${playerId}/isBanned`, {
headers: await bearer(sub),
})
// Auth-gated, but any authenticated caller may ask — a ban is not a secret from the
// player it stops.
expect((await SELF.fetch(`${ORIGIN}/Room_server/rooms/2/bans/205/isBanned`)).status).toBe(401)
// Nobody is banned from room 3.
const clean = await isBanned(3, 4242)
expect(clean.status).toBe(200)
// `success` says the CHECK ran; `value` is the answer. Note `error_id` is present and
// `error` is null — not the room mutations' `{ success, error, value }` with `""`.
expect(await clean.json()).toEqual({
success: true,
error: null,
error_id: null,
value: false,
})
// Ban someone from room 3, and the same call now says so.
await env.DB.prepare(
'INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)' +
" VALUES (?1, ?2, 0, 1, '2026-01-01T00:00:00Z')"
)
.bind(3, 4242)
.run()
expect(await (await isBanned(3, 4242)).json()).toMatchObject({ success: true, value: true })
// The ban is per (room, player): another room and another player are unaffected.
expect(await (await isBanned(2, 4242)).json()).toMatchObject({ value: false })
expect(await (await isBanned(3, 4243)).json()).toMatchObject({ value: false })
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2')
.bind(3, 4242)
.run()
})
it('GET /rooms/:id/bans/:playerId/isBanned answers the same check, PascalCase', async () => {
const isBanned = async (roomId: number, playerId: number) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/bans/${playerId}/isBanned`, {
headers: await bearer('300'),
})
// Same gate as the `/Room_server/` spelling: a token is needed, any token will do.
expect((await SELF.fetch(`${ORIGIN}/rooms/112/bans/1/isBanned`)).status).toBe(401)
const clean = await isBanned(112, 1)
expect(clean.status).toBe(200)
// PascalCase — `Value`/`Success`/`Error`, and `error_id` lowercase all the same. The
// client's decoder drops members it doesn't recognise, so the other route's lowercase
// keys here would decode as `false` rather than fail. Room 112 does not exist and the
// answer is still a clean `false`: the question is about the ban row, not the room.
expect(await clean.json()).toEqual({
Value: false,
Success: true,
Error: null,
error_id: null,
})
// The same `room_ban` rows the other route and `match` read.
await env.DB.prepare(
'INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)' +
" VALUES (?1, ?2, 0, 1, '2026-01-01T00:00:00Z')"
)
.bind(112, 1)
.run()
expect(await (await isBanned(112, 1)).json()).toEqual({
Value: true,
Success: true,
Error: null,
error_id: null,
})
// Per (room, player), like every other read of these rows.
expect(await (await isBanned(113, 1)).json()).toMatchObject({ Value: false })
expect(await (await isBanned(112, 2)).json()).toMatchObject({ Value: false })
// …and the prefixed route sees the same ban, in its own lowercase envelope.
const prefixed = await SELF.fetch(`${ORIGIN}/Room_server/rooms/112/bans/1/isBanned`, {
headers: await bearer('300'),
})
expect(await prefixed.json()).toEqual({
success: true,
error: null,
error_id: null,
value: true,
})
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2')
.bind(112, 1)
.run()
})
it('POST/DELETE /rooms/:id/leaderboards/:lid configures and removes a room’s leaderboard slots', async () => {
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
const del = async (path: string, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'DELETE',
headers: sub ? await bearer(sub) : {},
})
const slotsOf = async (roomId: number) =>
(
await env.DB.prepare(
'SELECT leaderboard_id, leaderboard_title, stat_format, sort_ascending FROM room_leaderboard WHERE room_id = ?1 ORDER BY leaderboard_id'
)
.bind(roomId)
.all()
).results
// The real client body, verbatim.
const body = { leaderboardTitle: 'full name', statFormat: '1', sortAscending: 'False' }
// No token → 401 (auth gate).
expect((await postForm('/rooms/2/leaderboards/1', body)).status).toBe(401)
expect((await del('/rooms/2/leaderboards/1')).status).toBe(401)
// A valid token but no role on the room → 403.
expect((await postForm('/rooms/2/leaderboards/1', body, '999')).status).toBe(403)
expect((await del('/rooms/2/leaderboards/1', '999')).status).toBe(403)
// The envelope both routes answer — PascalCase `Success`/`Error`, lowercase
// `error_id`, no entity. NOT the room mutations' lowercase `{ success, error, value }`.
const OK = { Success: true, Error: null, error_id: null }
// Unknown room → failure envelope.
expect(await (await postForm('/rooms/99999/leaderboards/1', body, '1')).json()).toEqual({
Success: false,
Error: 'This room does not exist!',
error_id: null,
})
// The owner configures slot 1 — a bare success, with the row persisted.
const ok = await postForm('/rooms/2/leaderboards/1', body, '1')
expect(ok.status).toBe(200)
expect(await ok.json()).toEqual(OK)
expect(await slotsOf(2)).toEqual([
{ leaderboard_id: 1, leaderboard_title: 'full name', stat_format: 1, sort_ascending: 0 },
])
// Re-posting the slot reconfigures the one row rather than appending — and the
// co-owner may do it. `sortAscending=True` parses case-insensitively.
const rewrite = await postForm(
'/rooms/2/leaderboards/1',
{ leaderboardTitle: 'lap time', statFormat: '2', sortAscending: 'True' },
'2'
)
expect(rewrite.status).toBe(200)
expect(await rewrite.json()).toEqual(OK)
expect(await slotsOf(2)).toEqual([
{ leaderboard_id: 1, leaderboard_title: 'lap time', stat_format: 2, sort_ascending: 1 },
])
// Slots are per room: slot 2 here and slot 1 of another room are their own rows.
expect((await postForm('/rooms/2/leaderboards/2', body, '1')).status).toBe(200)
expect((await postForm('/rooms/3/leaderboards/1', body, '1')).status).toBe(200)
expect(await slotsOf(2)).toHaveLength(2)
expect(await slotsOf(3)).toHaveLength(1)
// DELETE removes exactly the named slot.
const removed = await del('/rooms/2/leaderboards/1', '1')
expect(removed.status).toBe(200)
expect(await removed.json()).toEqual(OK)
expect(await slotsOf(2)).toEqual([
{ leaderboard_id: 2, leaderboard_title: 'full name', stat_format: 1, sort_ascending: 0 },
])
expect(await slotsOf(3)).toHaveLength(1)
// Deleting a slot that isn't configured is a rejection, not an HTTP error — the
// client tears boards down by deleting every slot blindly.
expect(await (await del('/rooms/2/leaderboards/1', '1')).json()).toEqual({
Success: false,
Error: 'This room has no such leaderboard!',
error_id: null,
})
// Clean up the surviving rows so this test leaves no trace.
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
})
it('POST /rooms/:id/bans takes a reason and a duration', async () => {
type Ban = { Reason: string | null; ExpiresAt: string | null; CreatedAt: string }
type Sent = { playerIds: number[]; data: { duration: number; isBan: boolean; message: string } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
// Standing in the room, so there is a session to kick them out of.
await putInRoom(310, 2)
// The client's body, verbatim.
const res = await postForm(
'/rooms/2/bans',
{ banMask: '0', id: '310', reason: 'test', durationMinutes: '30' },
'1'
)
const body = (await envOf(res)) as unknown as { success: boolean; value: Ban }
expect(body).toMatchObject({ success: true, value: { BannedPlayerId: 310, Reason: 'test' } })
// Lapses thirty minutes after it was issued, to the second.
expect(Date.parse(body.value.ExpiresAt!) - Date.parse(body.value.CreatedAt)).toBe(30 * 60_000)
// The kick carries the reason in its message — but NOT the ban's length, and is not a
// ban frame: a duration with `isBan` is what the client shows as a timed GAME ban.
const [kick] = (await (await hub().fetch('http://do/all')).json()) as Sent[]
expect(kick.playerIds).toEqual([310])
expect(kick.data).toMatchObject({ duration: 0, isBan: false })
expect(kick.data.message).toMatch(/Reason: test$/)
// Neither field is required: no duration is a permanent ban, and it still pushes 0.
const permanent = (await envOf(
await postForm('/rooms/2/bans', { banMask: '0', id: '311' }, '1')
)) as unknown as { value: Ban }
expect(permanent.value).toMatchObject({ Reason: null, ExpiresAt: null })
for (const d of ['', '0']) {
const v = (await envOf(
await postForm('/rooms/2/bans', { id: '312', durationMinutes: d }, '1')
)) as unknown as { value: Ban }
expect(v.value.ExpiresAt, d).toBeNull()
}
// A re-ban REPLACES the duration and reason — it neither extends nor keeps the old ones.
const rebanned = (await envOf(
await postForm('/rooms/2/bans', { id: '310' }, '1')
)) as unknown as { value: Ban }
expect(rebanned.value).toMatchObject({ Reason: null, ExpiresAt: null })
})
it('POST /rooms/:id/bans moves the banned player out of that room, into their dorm', async () => {
const presenceOf = (accountId: number) =>
env.DB.prepare(
`SELECT room_id, json_extract(data, '$.roomInstance.roomInstanceId') AS instance
FROM presence WHERE account_id = ?1`
)
.bind(accountId)
.first<{ room_id: number; instance: number }>()
const instanceId = 1000002 // what `putInRoom` puts room 2's players in
const isFull = async () =>
(
await env.DB.prepare('SELECT is_full FROM room_instance WHERE id = ?1')
.bind(instanceId)
.first<{ is_full: number }>()
)?.is_full
// A one-seat instance of room 2 that 320 is filling.
await env.DB.prepare('INSERT INTO room_instance (data) VALUES (?1)')
.bind(
JSON.stringify({
roomInstanceId: instanceId,
roomId: 2,
subRoomId: 2,
maxCapacity: 1,
isFull: true,
})
)
.run()
await putInRoom(320, 2)
// 321 is standing in a DIFFERENT room.
await putInRoom(321, 4)
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '320' }, '1')).status).toBe(200)
// Out of the room they were banned from, and the seat they held is free again. Their
// presence is REWRITTEN to their own dorm rather than deleted: with none to read, the
// client arrives at the dorm not knowing where it is and loads it a second time.
const moved = await presenceOf(320)
expect(moved).not.toBeNull()
expect(moved!.room_id).not.toBe(2)
// The OFFLINE dorm sentinel, not a live dorm session minted per ban.
expect(moved!.instance).toBe(-2)
expect(await isFull()).toBe(0)
// A ban from room 2 does not knock someone offline from room 4.
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '321' }, '1')).status).toBe(200)
expect((await presenceOf(321))?.room_id).toBe(4)
await clearPresence(321)
await env.DB.prepare('DELETE FROM room_instance WHERE id = ?1').bind(instanceId).run()
})
it('POST /rooms/:id/bans refuses a duration it cannot read, rather than banning forever', async () => {
for (const durationMinutes of ['thirty', '-5', '1.5']) {
expect(
await envOf(await postForm('/rooms/2/bans', { id: '313', durationMinutes }, '1')),
durationMinutes
).toMatchObject({ success: false, error: 'You must provide a valid ban duration!' })
}
const row = await env.DB.prepare(
'SELECT 1 FROM room_ban WHERE room_id = 2 AND banned_player_id = 313'
).first()
expect(row).toBeNull()
})
it('a lapsed room ban stops counting everywhere without being lifted', async () => {
// Written straight to the table: an hour-long ban issued two hours ago.
const issued = new Date(Date.now() - 2 * 3_600_000)
await env.DB.prepare(
`INSERT INTO room_ban
(room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at)
VALUES (2, 314, 0, 1, ?1, 'old', ?2)`
)
.bind(issued.toISOString(), new Date(issued.getTime() + 3_600_000).toISOString())
.run()
// `isBanned` — the same check `match` refuses matchmakes on.
const isBanned = await SELF.fetch(`${ORIGIN}/rooms/2/bans/314/isBanned`, {
headers: await bearer('314'),
})
expect(await isBanned.json()).toMatchObject({ Value: false })
// Not in the ban list.
const list = (await (
await SELF.fetch(`${ORIGIN}/rooms/2/bans`, { headers: await bearer('1') })
).json()) as Array<{ accountId: number }>
expect(list.some((b) => b.accountId === 314)).toBe(false)
// Unbanning it is "not banned", like any other player who isn't.
const unban = await SELF.fetch(`${ORIGIN}/rooms/2/bans/314`, {
method: 'DELETE',
headers: await bearer('1'),
})
expect(await unban.json()).toMatchObject({ success: false })
// A ban still in force DOES count — the filter is on the expiry, not on having one.
expect(
(await postForm('/rooms/2/bans', { id: '315', durationMinutes: '30' }, '1')).status
).toBe(200)
const stillBanned = await SELF.fetch(`${ORIGIN}/rooms/2/bans/315/isBanned`, {
headers: await bearer('315'),
})
expect(await stillBanned.json()).toMatchObject({ Value: true })
})
it('POST /rooms/:id/bans kicks a banned player out of the room — without banning them from the game', async () => {
type Sent = {
playerId?: number
playerIds?: number[]
ephemeral?: boolean
notificationType: string | number
data: unknown
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sentSince = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// The room's current name, read rather than hardcoded — earlier tests rename it.
const { Name } = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
await putInRoom(207, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)
// One EPHEMERAL ModerationKick to the banned player, out of the instance they were in.
// Asserted against the enum rather than a literal: the ids are notify's to change.
expect(await sentSince()).toEqual([
{
playerIds: [207],
ephemeral: true,
notificationType: NotificationType.ModerationKick,
// The client's moderation payload, camelCase, in wire order.
data: {
reportCategory: -1, // Moderator — a person acted, not the system
duration: 0,
gameSessionId: 1000002, // the instance `putInRoom` stood them in
// Account 1 owns RecCenter, so it hosts it.
isHostKick: true,
message: `You have been banned from ${Name}.`,
playerIdReporter: 1,
// FALSE. `isBan: true` is what the client shows as a ban from the whole game
// — the account ban's frame. A room ban is enforced by the row, when `match`
// refuses them the room; this frame only ejects them from the session.
isBan: false,
isVoiceModAutoban: false,
},
},
])
// A staff moderator doesn't host the room, so it isn't a host kick — and
// `playerIdReporter` is still whoever caused it.
await putInRoom(208, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect(
(await postForm('/rooms/2/bans', { id: '208' }, '999', ['gameClient', 'moderator'])).status
).toBe(200)
expect((await sentSince())[0]).toMatchObject({
playerIds: [208],
data: { isHostKick: false, playerIdReporter: 999, isBan: false },
})
// A player who is NOT in the room has nothing to be kicked out of, and hears NOTHING —
// not now, and not queued for their next connect, which is how a room ban once greeted
// players with a ban screen on login.
await putInRoom(216, 4)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await postForm('/rooms/2/bans', { id: '216' }, '1')).status).toBe(200)
expect((await postForm('/rooms/2/bans', { id: '217' }, '1')).status).toBe(200)
expect(await sentSince()).toEqual([])
for (const id of [216]) await clearPresence(id)
})
it('GET /rooms/:id/bans lists the room’s bans, under the same gate', async () => {
type Entry = { accountId: number; bannedByAccountId: number; banStartTime: string }
const list = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub, roles) : {} })
// Room 3 is owned by account 1 (it has no bans yet) — ban two players into it.
expect((await postForm('/rooms/3/bans', { id: '401' }, '1')).status).toBe(200)
expect((await postForm('/rooms/3/bans', { id: '402' }, '1')).status).toBe(200)
// No token → 401; a valid token with no room role and no staff role → 403.
expect((await list('/rooms/3/bans')).status).toBe(401)
expect((await list('/rooms/3/bans', '999')).status).toBe(403)
const res = await list('/rooms/3/bans', '1')
expect(res.status).toBe(200)
// A bare array in the client's camelCase shape — no room id, no ban mask.
const bans = (await res.json()) as Entry[]
expect(bans.map((b) => b.accountId).sort((a, b) => a - b)).toEqual([401, 402])
expect(bans[0]).toEqual({
accountId: expect.any(Number),
bannedByAccountId: 1,
banStartTime: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
})
// A staffer can read a list for a room they have no role on.
expect((await list('/rooms/3/bans', '999', ['gameClient', 'moderator'])).status).toBe(200)
// An unknown room reads the same as a room with nobody banned — no probing which
// room ids exist.
expect(await (await list('/rooms/99999/bans', '1')).json()).toEqual([])
})
it('GET /rooms/:id/bans/history serves the client’s exact shape', async () => {
const history = async (playerId: number) =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/2/bans/history?id=${playerId}`, {
headers: await bearer('1'),
})
).json()) as {
Value: {
ActiveBan: Record<string, unknown> | null
PreviousBans: Array<Record<string, unknown>>
}
Success: boolean
Error: string | null
error_id: string | null
}
expect(
(await postForm('/rooms/2/bans', { banMask: '0', id: '340', reason: 'test' }, '1')).status
).toBe(200)
const body = await history(340)
// The envelope, PascalCase with a lowercase `error_id`, and a Value of exactly two keys.
expect(Object.keys(body)).toEqual(['Value', 'Success', 'Error', 'error_id'])
expect(body).toMatchObject({ Success: true, Error: null, error_id: null })
expect(Object.keys(body.Value)).toEqual(['ActiveBan', 'PreviousBans'])
// Seven keys in the CLIENT'S order — derived members first, so AccountId is fifth.
const active = body.Value.ActiveBan!
expect(Object.keys(active)).toEqual([
'Status',
'UnbannedByAccountId',
'BanEndTime',
'Reason',
'AccountId',
'BannedByAccountId',
'BanStartTime',
])
expect(active).toMatchObject({
Status: 0, // Active
UnbannedByAccountId: null,
BanEndTime: null, // permanent
Reason: 'test',
AccountId: 340,
BannedByAccountId: 1,
})
// UTC to the second, as the client's example carries it.
expect(active.BanStartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
expect(body.Value.PreviousBans).toEqual([])
// A ban with no reason is `""`, never null; a timed one carries its scheduled end.
expect(
(await postForm('/rooms/2/bans', { id: '341', durationMinutes: '30' }, '1')).status
).toBe(200)
const timed = (await history(341)).Value.ActiveBan!
expect(timed.Reason).toBe('')
expect(Date.parse(timed.BanEndTime as string) - Date.parse(timed.BanStartTime as string)).toBe(
30 * 60_000
)
// Someone never banned has no history at all.
expect((await history(349)).Value).toEqual({ ActiveBan: null, PreviousBans: [] })
})
it('GET /rooms/:id/bans/history keeps lifted and elapsed bans, newest first', async () => {
type Rec = {
Status: number
UnbannedByAccountId: number | null
BanEndTime: string | null
Reason: string
}
const history = async (playerId: number) =>
(
(await (
await SELF.fetch(`${ORIGIN}/rooms/2/bans/history?id=${playerId}`, {
headers: await bearer('1'),
})
).json()) as { Value: { ActiveBan: Rec | null; PreviousBans: Rec[] } }
).Value
const unban = async (playerId: number, sub: string) =>
SELF.fetch(`${ORIGIN}/rooms/2/bans/${playerId}`, {
method: 'DELETE',
headers: await bearer(sub),
})
// LIFTED: banned by the owner, unbanned early by the co-owner.
expect((await postForm('/rooms/2/bans', { id: '342', reason: 'first' }, '1')).status).toBe(200)
expect((await unban(342, '2')).status).toBe(200)
let h = await history(342)
expect(h.ActiveBan).toBeNull()
expect(h.PreviousBans).toHaveLength(1)
expect(h.PreviousBans[0]).toMatchObject({ Status: 2, UnbannedByAccountId: 2, Reason: 'first' })
expect(h.PreviousBans[0].BanEndTime).not.toBeNull()
// Banned and lifted again: two previous bans, the newest first.
expect((await postForm('/rooms/2/bans', { id: '342', reason: 'second' }, '1')).status).toBe(200)
expect((await unban(342, '1')).status).toBe(200)
h = await history(342)
expect(h.PreviousBans.map((b) => b.Reason)).toEqual(['second', 'first'])
// ELAPSED: a one-hour ban issued two hours ago, never touched since. It is not active,
// and it is already reported as a previous ban before anything files it.
const issued = new Date(Date.now() - 2 * 3_600_000)
const expired = new Date(issued.getTime() + 3_600_000).toISOString()
await env.DB.prepare(
`INSERT INTO room_ban
(room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at)
VALUES (2, 343, 0, 1, ?1, 'lapsed', ?2)`
)
.bind(issued.toISOString(), expired)
.run()
h = await history(343)
expect(h.ActiveBan).toBeNull()
expect(h.PreviousBans).toEqual([
expect.objectContaining({ Status: 1, UnbannedByAccountId: null, Reason: 'lapsed' }),
])
// A new ban replaces the stale row — and FILES the lapsed one, exactly once, rather than
// losing it or counting it twice.
expect((await postForm('/rooms/2/bans', { id: '343', reason: 'again' }, '1')).status).toBe(200)
h = await history(343)
expect(h.ActiveBan).toMatchObject({ Status: 0, Reason: 'again' })
expect(h.PreviousBans).toEqual([
expect.objectContaining({ Status: 1, UnbannedByAccountId: null, Reason: 'lapsed' }),
])
// Elapsed at its expiry, not at the moment it was replaced.
expect(Date.parse(h.PreviousBans[0].BanEndTime!)).toBe(
Math.floor(Date.parse(expired) / 1000) * 1000
)
// Re-issuing a ban still in force AMENDS it: one active ban, nothing filed.
expect((await postForm('/rooms/2/bans', { id: '344', reason: 'v1' }, '1')).status).toBe(200)
expect((await postForm('/rooms/2/bans', { id: '344', reason: 'v2' }, '2')).status).toBe(200)
h = await history(344)
expect(h.ActiveBan).toMatchObject({ Reason: 'v2' })
expect(h.PreviousBans).toEqual([])
// The bulk unban files a lift the same way the single one does.
await SELF.fetch(`${ORIGIN}/rooms/2/bans/bulk`, {
method: 'DELETE',
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer('1')) },
body: 'banMask=0&id=344',
})
h = await history(344)
expect(h.ActiveBan).toBeNull()
expect(h.PreviousBans).toEqual([
expect.objectContaining({ Status: 2, UnbannedByAccountId: 1, Reason: 'v2' }),
])
})
it('GET /rooms/:id/bans/history is gated like the ban list', async () => {
const get = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub, roles) : {} })
expect((await postForm('/rooms/2/bans', { id: '345' }, '1')).status).toBe(200)
expect((await get('/rooms/2/bans/history?id=345')).status).toBe(401)
expect((await get('/rooms/2/bans/history?id=345', '999')).status).toBe(403)
// The co-owner, and a staff token with no role on the room.
for (const [sub, roles] of [
['2', undefined],
['999', ['gameClient', 'moderator']],
] as const) {
const res = await get('/rooms/2/bans/history?id=345', sub, roles as string[] | undefined)
expect(res.status, sub).toBe(200)
expect(
((await res.json()) as { Value: { ActiveBan: unknown } }).Value.ActiveBan
).not.toBeNull()
}
// An unknown room is an empty history, not an error.
expect(await (await get('/rooms/99999/bans/history?id=345', '999')).json()).toEqual({
Value: { ActiveBan: null, PreviousBans: [] },
Success: true,
Error: null,
error_id: null,
})
// A missing or non-numeric id.
for (const q of ['', '?id=nope']) {
expect(await (await get(`/rooms/2/bans/history${q}`, '1')).json(), q).toEqual({
Value: null,
Success: false,
Error: 'You must provide a valid player!',
error_id: null,
})
}
})
it('DELETE /rooms/:id/bans/bulk removes the named bans, under the same gate', async () => {
type Removed = {
success: boolean
error: string
value: Array<{ BannedPlayerId: number }> | null
}
const unban = async (body: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}/rooms/2/bans/bulk`, {
method: 'DELETE',
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(sub ? await bearer(sub, roles) : {}),
},
body,
})
const banned = async (playerId: number) =>
(await env.DB.prepare('SELECT 1 FROM room_ban WHERE room_id = 2 AND banned_player_id = ?1')
.bind(playerId)
.first()) !== null
for (const id of ['330', '331', '332', '333']) {
expect((await postForm('/rooms/2/bans', { id }, '1')).status).toBe(200)
}
// No token → 401; a valid token with no standing in the room → 403. Nothing removed.
expect((await unban('banMask=0&id=330')).status).toBe(401)
expect((await unban('banMask=0&id=330', '999')).status).toBe(403)
expect(await banned(330)).toBe(true)
// The client's body, verbatim, from the owner.
const one = await unban('banMask=0&id=330', '1')
expect(one.status).toBe(200)
expect((await one.json()) as Removed).toMatchObject({
success: true,
error: '',
value: [{ BannedPlayerId: 330 }],
})
expect(await banned(330)).toBe(false)
// Repeated `id` lifts every one of them — not just the last — from a co-owner, and a player
// who is not banned (330, already lifted) is skipped rather than failing the others.
const many = (await (await unban('banMask=0&id=331&id=330&id=332', '2')).json()) as Removed
expect(many).toMatchObject({ success: true })
expect(many.value!.map((b) => b.BannedPlayerId)).toEqual([331, 332])
expect(await banned(331)).toBe(false)
expect(await banned(332)).toBe(false)
// A staff token works in a room it has no role on, and a comma-separated id is read too.
expect(
(await (await unban('id=333,334', '999', ['gameClient', 'moderator'])).json()) as Removed
).toMatchObject({ success: true, value: [{ BannedPlayerId: 333 }] })
// Nothing banned among the names is still a success, with nothing removed.
expect((await (await unban('id=330', '1')).json()) as Removed).toMatchObject({
success: true,
value: [],
})
// A body naming no valid player at all is the one rejection.
for (const body of ['banMask=0', 'id=nope']) {
expect((await (await unban(body, '1')).json()) as Removed, body).toMatchObject({
success: false,
error: 'You must provide a valid player to unban!',
value: null,
})
}
})
it('DELETE /rooms/:id/bans/:playerId lifts a ban, under the same gate', async () => {
const del = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'DELETE',
headers: sub ? await bearer(sub, roles) : {},
})
const isBanned = async (roomId: number, playerId: number) =>
(await env.DB.prepare(
'SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2'
)
.bind(roomId, playerId)
.first()) !== null
// Two bans to lift: one removed by the owner, one by a staffer.
expect((await postForm('/rooms/2/bans', { id: '305' }, '1')).status).toBe(200)
expect((await postForm('/rooms/2/bans', { id: '306' }, '1')).status).toBe(200)
// No token → 401; a valid token with no room role and no staff role → 403.
expect((await del('/rooms/2/bans/305')).status).toBe(401)
expect((await del('/rooms/2/bans/305', '999')).status).toBe(403)
expect(await isBanned(2, 305)).toBe(true)
// Unknown room → failure envelope.
expect(await envOf(await del('/rooms/99999/bans/305', '1'))).toMatchObject({
success: false,
error: 'This room does not exist!',
})
// The owner lifts it; the removed ban comes back as `value`.
const ok = await del('/rooms/2/bans/305', '1')
expect(ok.status).toBe(200)
expect(await envOf(ok)).toMatchObject({
success: true,
error: '',
value: { RoomId: 2, BannedPlayerId: 305 },
})
expect(await isBanned(2, 305)).toBe(false)
// Unbanning someone who isn't banned is a rejection, not a silent success.
expect(await envOf(await del('/rooms/2/bans/305', '1'))).toMatchObject({
success: false,
error: 'This player is not banned from this room!',
value: null,
})
// A staff token may lift a ban in a room they have no role on.
expect((await del('/rooms/2/bans/306', '999', ['gameClient', 'developer'])).status).toBe(200)
expect(await isBanned(2, 306)).toBe(false)
})
it('PUT /rooms/:id/warning is auth-gated, owner/co-owner-only, and persists', async () => {
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/warning', { warningMask: '2' })).status).toBe(401)
// A valid token but no role on the room → 403.
expect((await putForm('/rooms/2/warning', { warningMask: '2' }, '999')).status).toBe(403)
// Unknown room → failure envelope.
expect(
await envOf(await putForm('/rooms/99999/warning', { warningMask: '2' }, '1'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
// Non-numeric mask → failure envelope.
expect(await envOf(await putForm('/rooms/2/warning', { warningMask: 'x' }, '1'))).toMatchObject(
{ success: false }
)
// Owner sets it, and it persists as an integer (not 2.0). The success envelope
// carries the updated room as `value`.
const ok = await putForm('/rooms/2/warning', { warningMask: '2' }, '1')
expect(ok.status).toBe(200)
const okBody = await envOf(ok)
expect(okBody).toMatchObject({ success: true, error: '' })
expect(okBody.value?.WarningMask).toBe(2)
const raw = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
.bind(2)
.first<{ data: string }>()
expect(raw!.data).toContain('"WarningMask":2')
expect(raw!.data).not.toContain('"WarningMask":2.0')
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { WarningMask: number }
expect(room.WarningMask).toBe(2)
// The mask can carry an optional free-text CustomWarning alongside it.
const custom = await envOf(
await putForm('/rooms/2/warning', { warningMask: '63', customWarning: 'slfkjsdf' }, '1')
)
expect(custom).toMatchObject({ success: true })
expect(custom.value).toMatchObject({ WarningMask: 63, CustomWarning: 'slfkjsdf' })
const withCustom = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
WarningMask: number
CustomWarning: string
}
expect(withCustom).toMatchObject({ WarningMask: 63, CustomWarning: 'slfkjsdf' })
// Omitting customWarning leaves the existing text untouched (partial update).
await putForm('/rooms/2/warning', { warningMask: '7' }, '1')
const kept = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { CustomWarning: string }
expect(kept.CustomWarning).toBe('slfkjsdf')
// The co-owner (account 2, Role 30) may also set it.
expect((await putForm('/rooms/2/warning', { warningMask: '4' }, '2')).status).toBe(200)
})
it('PUT /rooms/:id/cloning is auth-gated, owner/co-owner-only, and persists a JSON boolean', async () => {
// No token → 401.
expect((await putForm('/rooms/2/cloning', { cloningAllowed: 'False' })).status).toBe(401)
// A valid token but no role on the room → 403.
expect((await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '999')).status).toBe(403)
// Unknown room → failure envelope.
expect(
await envOf(await putForm('/rooms/99999/cloning', { cloningAllowed: 'False' }, '1'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
// Owner disables cloning; it persists as a real JSON boolean (not 0/1). The
// success envelope carries the updated room as `value`.
const disabled = await envOf(
await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '1')
)
expect(disabled).toMatchObject({ success: true, error: '' })
expect(disabled.value?.CloningAllowed).toBe(false)
const raw = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
.bind(2)
.first<{ data: string }>()
expect(raw!.data).toContain('"CloningAllowed":false')
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
CloningAllowed: boolean
}
expect(room.CloningAllowed).toBe(false)
// The co-owner (account 2) may re-enable it.
await putForm('/rooms/2/cloning', { cloningAllowed: 'True' }, '2')
const reenabled = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
CloningAllowed: boolean
}
expect(reenabled.CloningAllowed).toBe(true)
})
it('PUT /rooms/:id/restrictions sets the Supports* flags present in the body', async () => {
// No token → 401; a valid token with no role → 403.
expect((await putForm('/rooms/2/restrictions', { supportsScreens: 'True' })).status).toBe(401)
expect(
(await putForm('/rooms/2/restrictions', { supportsScreens: 'True' }, '999')).status
).toBe(403)
// The exact client body: a mix of True/False across a subset of the flags.
const res = await SELF.fetch(`${ORIGIN}/rooms/2/restrictions`, {
method: 'PUT',
headers: { ...(await bearer('1')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'supportsScreens=True&supportsWalkVR=True&supportsTeleportVR=False&supportsJuniors=True',
})
expect(res.status).toBe(200)
// The success envelope carries the updated room as `value`.
const env2 = await envOf(res)
expect(env2).toMatchObject({ success: true, error: '' })
expect(env2.value).toMatchObject({
SupportsScreens: true,
SupportsWalkVR: true,
SupportsTeleportVR: false,
SupportsJuniors: true,
})
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
SupportsScreens: boolean
SupportsWalkVR: boolean
SupportsTeleportVR: boolean
SupportsJuniors: boolean
SupportsMobile: boolean
}
expect(room.SupportsScreens).toBe(true)
expect(room.SupportsWalkVR).toBe(true)
expect(room.SupportsTeleportVR).toBe(false)
expect(room.SupportsJuniors).toBe(true)
// A flag not in the body is left unchanged (still a boolean, not dropped).
expect(typeof room.SupportsMobile).toBe('boolean')
})
it('PUT /rooms/:id/loadscreen replaces the load screen (auth-gated, owner/co-owner-only)', async () => {
const screensOf = async (): Promise<Array<Record<string, unknown>>> => {
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
LoadScreens?: Array<Record<string, unknown>>
}
return room.LoadScreens ?? []
}
// No token → 401; a valid token with no role → 403.
expect((await putForm('/rooms/2/loadscreen', { imageName: 'a.jpg' })).status).toBe(401)
expect((await putForm('/rooms/2/loadscreen', { imageName: 'a.jpg' }, '999')).status).toBe(403)
// Unknown room → failure envelope.
expect(
await envOf(await putForm('/rooms/99999/loadscreen', { imageName: 'a.jpg' }, '1'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
// Missing image → failure envelope.
expect(await envOf(await putForm('/rooms/2/loadscreen', { title: 'x' }, '1'))).toMatchObject({
success: false,
})
// Owner sets one (imageName + title + subtitle) — the success envelope carries the
// updated room, and the posted screen is the ONLY entry.
const added = await envOf(
await putForm(
'/rooms/2/loadscreen',
{ imageName: 'sharecamera/2026-07-15/abc.jpg', title: 'asdf', subtitle: 'sdf' },
'1'
)
)
expect(added).toMatchObject({ success: true })
expect(added.value?.LoadScreens).toEqual([
{ ImageName: 'sharecamera/2026-07-15/abc.jpg', Title: 'asdf', Subtitle: 'sdf' },
])
expect(await screensOf()).toHaveLength(1)
// A second call REPLACES rather than appending (the client renders one screen, so
// an appended one would sit unreachable behind the old); title/subtitle default to
// empty when omitted.
const co = await envOf(await putForm('/rooms/2/loadscreen', { imageName: 'second.jpg' }, '2'))
expect(co).toMatchObject({ success: true })
expect(await screensOf()).toEqual([{ ImageName: 'second.jpg', Title: '', Subtitle: '' }])
})
it('PUT /rooms/:id/accessibility sets the room-level Accessibility (auth-gated, owner/co-owner-only)', async () => {
// No token → 401; a valid token with no role → 403.
expect((await putForm('/rooms/2/accessibility', { accessibility: '1' })).status).toBe(401)
expect((await putForm('/rooms/2/accessibility', { accessibility: '1' }, '999')).status).toBe(
403
)
// Unknown room → failure envelope.
expect(
await envOf(await putForm('/rooms/99999/accessibility', { accessibility: '1' }, '1'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
// Non-numeric → failure envelope.
expect(
await envOf(await putForm('/rooms/2/accessibility', { accessibility: 'x' }, '1'))
).toMatchObject({ success: false })
// Owner sets it (0 = Private); the success envelope carries the updated room.
const priv = await envOf(await putForm('/rooms/2/accessibility', { accessibility: '0' }, '1'))
expect(priv).toMatchObject({ success: true, error: '' })
expect(priv.value?.Accessibility).toBe(0)
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Accessibility: number }
expect(room.Accessibility).toBe(0)
// The co-owner (account 2) may set it back to public (1).
const pub = await envOf(await putForm('/rooms/2/accessibility', { accessibility: '1' }, '2'))
expect(pub.value?.Accessibility).toBe(1)
})
it('there is no GET for a single subroom — only the room carries them', async () => {
// The real API has no `GET …/subrooms/{id}/data`; the client reads subrooms off the
// room. Only the POST (the room save) exists on that path, and it is auth-gated.
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).status).toBe(404)
expect(await subRoomOf(2, 2)).toMatchObject({ SubRoomId: 2 })
})
it('POST /rooms/:id/subrooms/:sid/data is auth-gated, owner-only, and saves the blobs', async () => {
const save = {
UnityAssetId: null,
RoomData: { Filename: '5c618c920f6247efb8327e327d0b4417', Hash: null, OwnershipProof: null },
SubRoomData: {
Filename: 'a84167b16796452ab70ee8a6a5b1dc5f',
Hash: null,
OwnershipProof: null,
},
InventionUsage: 'CAE=',
PersistenceVersion: 41,
Description: 'mydescription here',
AutoPublish: true,
}
// No token → 401.
expect(
(
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(save),
})
).status
).toBe(401)
const authed = async (roomId: number, subRoomId: number, sub = '1') =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer(sub)) },
body: JSON.stringify(save),
})
// A valid token but no role on the room → 403.
expect((await authed(2, 2, '999')).status).toBe(403)
// Rejections use the same lowercase envelope as the success case.
expect(await envOf(await authed(99999, 2, '1'))).toMatchObject({
success: false,
error: 'This room does not exist!',
})
expect(await envOf(await authed(2, 9999, '1'))).toMatchObject({ success: false })
// The room's own fields are read first: a save is a revision of a SUBROOM and must
// leave them alone. `Description` in the body is the save comment, not the room's
// description — that is `PUT /rooms/:id/description`'s to set.
const before = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Description: string
PersistenceVersion: number
}
expect(before.Description).not.toBe('mydescription here')
// Owner saves → 200. `value` carries BOTH the updated room and the new save, and
// `error` is null (not ''). This fixture sends `AutoPublish: true`, so it goes live.
const ok = await authed(2, 2, '1')
expect(ok.status).toBe(200)
const saved = (await ok.json()) as {
success: boolean
error: string | null
value: {
room: Record<string, unknown>
subRoomDataSave: Record<string, unknown>
}
}
expect(saved.success).toBe(true)
expect(saved.error).toBeNull()
expect(saved.value.room).toMatchObject({ RoomId: 2, Description: before.Description })
// The save is a camelCase projection, NOT the PascalCase CurrentSave shape.
expect(saved.value.subRoomDataSave).toEqual({
subRoomDataSaveId: expect.any(Number),
subRoomId: 2,
unityAssetId: null,
unityAsset: null,
unityAssetHash: null,
dataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
dataBlobHash: null,
savedByAccountId: 1,
savedOnPlatform: 0,
savedOnDeviceClass: 0,
description: 'mydescription here',
createdAt: expect.any(String),
})
// The saved subroom rides along inside the room's SubRooms, carrying the new save.
const savedSub = (saved.value.room.SubRooms as Array<Record<string, unknown>>).find(
(s) => s.SubRoomId === 2
)!
expect(savedSub).toMatchObject({
RoomDataBlob: '5c618c920f6247efb8327e327d0b4417',
CreatorAccountId: 1,
PersistenceVersion: 41,
})
expect(savedSub.CurrentSave).toMatchObject({
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
})
// It also persists — reading the room back shows the save live.
const sub = (await subRoomOf(2, 2)) as unknown as {
SubRoomId: number
CreatorAccountId: number
CurrentSave: {
DataBlob: string
SubRoomDataSaveId: number
SavedByAccountId: number
PersistenceVersion: number
Description: string
UnitySubAssets: unknown[]
Tags: unknown[]
}
StagedSubRoomDataSaveId: number | null
}
expect(sub).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 })
expect(sub.CurrentSave).toMatchObject({
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
SavedByAccountId: 1,
PersistenceVersion: 41,
UnitySubAssets: [],
Tags: [],
})
expect(sub.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(0)
expect(sub.StagedSubRoomDataSaveId).toBeNull()
// The save comment and the scene fields land on the SUBROOM's revision, and the
// room's own fields are untouched — a save must never rewrite the room.
expect(sub.CurrentSave.Description).toBe('mydescription here')
expect(sub).toMatchObject({ PersistenceVersion: 41, InventionUsage: 'CAE=' })
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Description: string
PersistenceVersion: number
InventionUsage?: string
}
expect(room.Description).toBe(before.Description)
expect(room.PersistenceVersion).toBe(before.PersistenceVersion)
expect(room.InventionUsage).toBeUndefined()
// A CoOwner (account 2 holds Role 30 in the seeded rooms) may also save — 200
// with the room envelope. The creator stays account 1 (not clobbered).
const coOwner = await authed(2, 2, '2')
expect(coOwner.status).toBe(200)
const coOwnerEnv = (await coOwner.json()) as {
success: boolean
value: { room: { SubRooms: Array<Record<string, unknown>> }; subRoomDataSave: unknown }
}
expect(coOwnerEnv.success).toBe(true)
expect(coOwnerEnv.value.room.SubRooms.find((s) => s.SubRoomId === 2)).toMatchObject({
CreatorAccountId: 1,
})
// The save records who actually saved it, not the room's creator.
expect(coOwnerEnv.value.subRoomDataSave).toMatchObject({ savedByAccountId: 2 })
})
it('GET /rooms/:id gives every subroom a CurrentSave key (null before the first save)', async () => {
// The client loads a subroom's scene data from CurrentSave and nothing else, so
// the key must be PRESENT — the seeded rooms predate it and have no such field in
// their stored blob. `in` rather than a value check: absent and null differ here.
// Room 3 is seeded and never saved by another test (room 2 is the save fixture).
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/3`)).json()) as {
SubRooms: Array<Record<string, unknown>>
}
expect(room.SubRooms.length).toBeGreaterThan(0)
for (const sub of room.SubRooms) {
expect('CurrentSave' in sub).toBe(true)
expect(sub.CurrentSave).toBeNull()
}
})
it('a real client room-save body stages, and publish_save makes it live', async () => {
const save = async (body: unknown) =>
SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
body: JSON.stringify(body),
})
type Sub = {
CurrentSave: Record<string, unknown> | null
StagedSubRoomDataSaveId: number | null
}
const subOf = async () => (await subRoomOf(5, 5)) as unknown as Sub
const publish = async (saveId: number, sub = '1') =>
SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/publish_save`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ subRoomDataSaveId: String(saveId) }).toString(),
})
// The exact body the live client posts after uploading both blobs to `storage`:
// SubRoomData is the scene blob, RoomData the metadata blob.
const res = await save({
UnityAssetId: null,
RoomData: { Filename: '2026-07-28/b266ccd5-metadata', Hash: null, OwnershipProof: null },
SubRoomData: { Filename: '2026-07-28/f176fc3b-scene', Hash: null, OwnershipProof: null },
InventionUsage: 'CAE=',
PersistenceVersion: 51,
Description: 'TEST',
AutoPublish: false,
})
expect(res.status).toBe(200)
// Room 5 is not a dorm → staged, nothing live yet.
const stagedSub = await subOf()
expect(stagedSub.CurrentSave).toBeNull()
const firstId = stagedSub.StagedSubRoomDataSaveId!
expect(firstId).toBeGreaterThan(0)
// Publishing makes it what the loader fetches, and clears the staging slot.
expect((await publish(firstId)).status).toBe(200)
const live = await subOf()
expect(live.StagedSubRoomDataSaveId).toBeNull()
expect(live.CurrentSave).toMatchObject({
SubRoomId: 5,
SubRoomDataSaveId: firstId,
DataBlob: '2026-07-28/f176fc3b-scene',
PersistenceVersion: 51,
SavedByAccountId: 1,
Description: 'TEST',
OMVersion: 0,
UgcSubVersion: 0,
ModerationState: 0,
})
// DataBlobHash rides along (null — the client sent `Hash: null`); UnityAssetId is
// omitted entirely rather than nulled, since the save carried none.
expect(live.CurrentSave!.DataBlobHash).toBeNull()
expect('UnityAssetId' in live.CurrentSave!).toBe(false)
// It's on the room read too — that's what the loader actually fetches.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/5`)).json()) as {
SubRooms: Array<{ SubRoomId: number; CurrentSave: { SubRoomDataSaveId: number } | null }>
}
expect(room.SubRooms.find((s) => s.SubRoomId === 5)!.CurrentSave!.SubRoomDataSaveId).toBe(
firstId
)
// A second save appends and stages — what players load does NOT change.
await save({ SubRoomData: { Filename: 'second.room' } })
const afterSecond = await subOf()
const secondId = afterSecond.StagedSubRoomDataSaveId!
expect(secondId).toBeGreaterThan(firstId)
expect(afterSecond.CurrentSave).toMatchObject({ SubRoomDataSaveId: firstId })
// Both saves are in the history, newest first — the first one is not lost.
const history = (await (
await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/saves`, { headers: await bearer('1') })
).json()) as {
Results: Array<{ DataBlob: string; Description: string }>
TotalResults: number
}
expect(history.TotalResults).toBe(2)
expect(history.Results.map((s) => s.DataBlob)).toEqual([
'second.room',
'2026-07-28/f176fc3b-scene',
])
// A save with no Description records an empty string, not null.
expect(history.Results[0]!.Description).toBe('')
// Publishing an OLDER save is a restore — and keeps the newer staged work.
expect((await publish(secondId)).status).toBe(200)
expect((await subOf()).StagedSubRoomDataSaveId).toBeNull()
expect((await publish(firstId)).status).toBe(200)
const restored = await subOf()
expect(restored.CurrentSave).toMatchObject({ SubRoomDataSaveId: firstId })
// A save id from a different subroom is rejected, even though ids are global.
const foreign = (await (
await publish(
((await subRoomOf(2, 2)) as unknown as Sub).CurrentSave!.SubRoomDataSaveId as number
)
).json()) as { success: boolean; error: string }
expect(foreign.success).toBe(false)
expect(foreign.error).toBe('That save does not exist!')
// Co-owners may save but not publish.
expect(((await (await publish(firstId, '2')).json()) as { success: boolean }).success).toBe(
false
)
})
it('migrates a pre-CurrentSave subroom into a real save row (0008 backfill 2)', async () => {
// A subroom saved by the older code has its blob in the flat DataBlob field and no
// CurrentSave at all. seedRoomWithSubRooms mirrors the migration, so this covers
// the backfill: the flat fields become a save row the subroom points at, rather
// than reading as never-saved and hiding real content from the loader.
await seedRoomWithSubRooms(env.DB, {
RoomId: 820,
Name: 'LegacyShaped',
CreatorAccountId: 1,
SubRooms: [
{
SubRoomId: 830,
Name: 'Legacy',
CreatorAccountId: 7,
UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163',
MaxPlayers: 4,
Accessibility: 2,
DataBlob: 'legacy-blob.room',
DataSavedAt: '2024-03-04T05:06:07.000Z',
PersistenceVersion: 12,
},
],
})
const sub = (await subRoomOf(820, 830)) as unknown as {
CurrentSave: Record<string, unknown>
}
expect(sub.CurrentSave).toMatchObject({
SubRoomId: 830,
DataBlob: 'legacy-blob.room',
PersistenceVersion: 12,
SavedByAccountId: 7,
CreatedAt: '2024-03-04T05:06:07.000Z',
UnitySubAssets: [],
Tags: [],
})
// Stable across reads — it's a stored row now, not something rebuilt per request.
const again = (await subRoomOf(820, 830)) as unknown as {
CurrentSave: { SubRoomDataSaveId: number }
}
expect(again.CurrentSave.SubRoomDataSaveId).toBe(sub.CurrentSave.SubRoomDataSaveId)
})
it('a dorm save publishes immediately instead of staging', async () => {
// Room 1 is the seeded DormRoom (IsDorm). A dorm is the player's own space with no
// publish step in the client, so staging one would make their edits permanently
// invisible — dorm saves go straight live.
const res = await SELF.fetch(`${ORIGIN}/rooms/1/subrooms/1/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
body: JSON.stringify({ SubRoomData: { Filename: 'dorm.room' } }),
})
expect(res.status).toBe(200)
const sub = (await subRoomOf(1, 1)) as unknown as {
CurrentSave: { DataBlob: string } | null
StagedSubRoomDataSaveId: number | null
}
expect(sub.CurrentSave).toMatchObject({ DataBlob: 'dorm.room' })
expect(sub.StagedSubRoomDataSaveId).toBeNull()
})
it('save ids are globally unique across subrooms, so a bare id resolves', async () => {
// StagedSubRoomDataSaveId points at a save by bare id with no subroom context, so
// per-subroom numbering (every subroom's first save being 1) would be ambiguous.
const save = async (roomId: number, subRoomId: number) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
body: JSON.stringify({ SubRoomData: { Filename: `blob-${subRoomId}.room` } }),
})
// Non-dorm saves stage, so the fresh id lands on StagedSubRoomDataSaveId.
const idOf = async (roomId: number, subRoomId: number) =>
((await subRoomOf(roomId, subRoomId)) as unknown as { StagedSubRoomDataSaveId: number })
.StagedSubRoomDataSaveId
// Two different subrooms, each getting their FIRST save.
await save(6, 6)
await save(7, 7)
expect(await idOf(6, 6)).not.toBe(await idOf(7, 7))
})
it('a cloned subroom re-points CurrentSave at the copy, not the source', async () => {
// Save room 2's subroom so there is a CurrentSave to copy.
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`, {
method: 'POST',
headers: { ...(await bearer('1')), 'Content-Type': 'application/json' },
body: JSON.stringify({ SubRoomData: { Filename: 'cloned-source.room' } }),
})
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
method: 'POST',
headers: await bearer('1'),
})
const body = (await res.json()) as {
value: { SubRooms: Array<{ SubRoomId: number; CurrentSave: { SubRoomId: number } | null }> }
}
const clone = body.value.SubRooms.find((s) => s.SubRoomId !== 2 && s.CurrentSave !== null)!
expect(clone).toBeDefined()
// The copy's save must claim the COPY, or the client resolves it against the source.
expect(clone.CurrentSave!.SubRoomId).toBe(clone.SubRoomId)
})
it('PUT /rooms/:id/tags is auth-gated, owner/co-owner-only, and toggles (add/remove)', async () => {
// The lowercase `{ success, error, value }` envelope this endpoint returns.
type TagResult = {
success: boolean
error: string
value: { Tags?: Array<{ Tag: string }> } | null
}
const envOf = async (res: Response) => (await res.json()) as TagResult
const tagsIn = (r: TagResult) => (r.value?.Tags ?? []).map((t) => t.Tag)
// No token → 401.
expect((await putForm('/rooms/2/tags', { tag: 'quest' })).status).toBe(401)
// A valid token but no role on the room → 403.
expect((await putForm('/rooms/2/tags', { tag: 'quest' }, '999')).status).toBe(403)
// Unknown room → failure envelope.
expect(await envOf(await putForm('/rooms/99999/tags', { tag: 'quest' }, '1'))).toMatchObject({
success: false,
error: 'This room does not exist!',
})
// Empty tag → failure envelope.
expect(await envOf(await putForm('/rooms/2/tags', { tag: ' ' }, '1'))).toMatchObject({
success: false,
error: 'You must provide a tag!',
})
// Owner adds a non-main tag → success envelope carries the updated room.
const added = await envOf(await putForm('/rooms/2/tags', { tag: 'spooky' }, '1'))
expect(added).toMatchObject({ success: true, error: '' })
expect(tagsIn(added)).toContain('spooky')
// The same call again toggles it back off (no delete endpoint).
const removed = await envOf(await putForm('/rooms/2/tags', { tag: 'SPOOKY' }, '1'))
expect(tagsIn(removed)).not.toContain('spooky')
// Main tags are radio buttons: setting one clears any other main tag, but
// leaves non-main tags alone.
await putForm('/rooms/2/tags', { tag: 'campfire' }, '1') // non-main, stays put
const pvp = await envOf(await putForm('/rooms/2/tags', { tag: 'pvp' }, '1'))
expect(tagsIn(pvp)).toEqual(expect.arrayContaining(['pvp', 'campfire']))
const quest = await envOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '1'))
expect(tagsIn(quest)).toContain('quest')
expect(tagsIn(quest)).not.toContain('pvp') // the previous main tag was cleared
expect(tagsIn(quest)).toContain('campfire') // non-main tag untouched
// Toggling the current main tag off just removes it (no other change).
const off = await envOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '1'))
expect(tagsIn(off)).not.toContain('quest')
expect(tagsIn(off)).toContain('campfire')
// The co-owner (account 2, Role 30) may edit tags too.
const byCoOwner = await envOf(await putForm('/rooms/2/tags', { tag: 'spooky' }, '2'))
expect(byCoOwner).toMatchObject({ success: true, error: '' })
expect(tagsIn(byCoOwner)).toContain('spooky')
})
it('PUT /rooms/:id/tags sets the primary genre from primaryGenreTag', async () => {
type TagResult = {
success: boolean
error: string
value: { Tags?: Array<{ Tag: string; Type: number; IsPrimaryGenre?: boolean }> } | null
}
const tags = async (res: Response) => ((await res.json()) as TagResult).value?.Tags ?? []
const genre = (list: Awaited<ReturnType<typeof tags>>) =>
list.filter((t) => t.IsPrimaryGenre).map((t) => t.Tag)
// Same gates as the toggle body — the second shape doesn't open a second door.
expect((await putForm('/rooms/4/tags', { primaryGenreTag: 'social' })).status).toBe(401)
expect((await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '999')).status).toBe(403)
// A blank value is a malformed post, not "unset the genre".
expect(
await (await putForm('/rooms/4/tags', { primaryGenreTag: ' ' }, '1')).json()
).toMatchObject({ success: false, error: 'You must provide a tag!' })
// Room 4 is seeded with the auto-derived `rro` (Type 2); add an ordinary tag too, so
// the genre can be seen not to disturb either of them.
await putForm('/rooms/4/tags', { tag: 'puzzle' }, '1')
// The genre tag is ADDED when the room lacks it — a Type 0 tag, flagged.
const set = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '1'))
expect(set).toContainEqual({ Tag: 'social', Type: 0, IsPrimaryGenre: true })
// …and the key is absent on the others rather than false.
expect(set).toContainEqual({ Tag: 'puzzle', Type: 0 })
// Choosing another genre MOVES the flag. Unlike the old five-way radio it does not
// remove the tag it displaced: `social` stays on the room, just no longer the genre.
const moved = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'horror' }, '1'))
expect(genre(moved)).toEqual(['horror'])
expect(moved.map((t) => t.Tag).sort()).toEqual(['horror', 'puzzle', 'rro', 'social'])
// A tag the room already carries keeps its Type and simply becomes the genre — the
// seeded `rro` is Type 2 and stays Type 2.
const promoted = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'rro' }, '1'))
expect(promoted).toContainEqual({ Tag: 'rro', Type: 2, IsPrimaryGenre: true })
expect(genre(promoted)).toEqual(['rro'])
// Stored on the row, so a cold read says the same thing.
expect(
await env.DB.prepare(
'SELECT tag, is_primary_genre FROM room_tag WHERE room_id = 4 AND is_primary_genre = 1'
).all<{ tag: string; is_primary_genre: number }>()
).toMatchObject({ results: [{ tag: 'rro', is_primary_genre: 1 }] })
const read = (await (await SELF.fetch(`${ORIGIN}/rooms/4`)).json()) as {
Tags: Array<{ Tag: string; IsPrimaryGenre?: boolean }>
}
expect(read.Tags.filter((t) => t.IsPrimaryGenre).map((t) => t.Tag)).toEqual(['rro'])
// The toggle body still works on the same room, and toggling the flagged tag off
// takes the genre with it — the room's genre WAS that tag.
const toggledOff = await tags(await putForm('/rooms/4/tags', { tag: 'rro' }, '1'))
expect(toggledOff.map((t) => t.Tag)).not.toContain('rro')
expect(genre(toggledOff)).toEqual([])
// …while toggling an unrelated tag leaves a genre alone.
await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '1')
const other = await tags(await putForm('/rooms/4/tags', { tag: 'campfire' }, '1'))
expect(genre(other)).toEqual(['social'])
// A `tag` alongside the genre is a whole-state save, NOT a toggle: `campfire` stays
// rather than being toggled back off. (The set semantics themselves are next.)
const both = await tags(
await putForm('/rooms/4/tags', { tag: 'campfire', primaryGenreTag: 'puzzle' }, '1')
)
expect(genre(both)).toEqual(['puzzle'])
expect(both.map((t) => t.Tag)).toContain('campfire')
// Put room 4 back the way it was seeded — its `rro` tag is what the rro feeds count.
await env.DB.batch([
env.DB.prepare('DELETE FROM room_tag WHERE room_id = 4'),
env.DB.prepare(
"INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (4, 'rro', 2, 0)"
),
])
})
it('PUT /rooms/:id/tags takes the whole-state save: repeated tag, autoTag, genre', async () => {
type Tag = { Tag: string; Type: number; IsPrimaryGenre?: boolean }
// A room of this test's own: the whole-state save REPLACES the user tags, and doing
// that to a seeded room would strip tags the feeds above are asserted on.
const ROOM = 9700
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: ROOM,
Name: 'TagSaveRoom',
CreatorAccountId: 1,
IsDorm: false,
Accessibility: 1,
SubRooms: [],
})
)
.run()
const save = async (query: string, sub = '1') => {
const res = await SELF.fetch(`${ORIGIN}/rooms/${ROOM}/tags`, {
method: 'PUT',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: query,
})
expect(res.status).toBe(200)
const body = (await res.json()) as { success: boolean; value: { Tags?: Tag[] } | null }
expect(body.success).toBe(true)
return [...(body.value?.Tags ?? [])].sort((a, b) => a.Tag.localeCompare(b.Tag))
}
// The form room settings posts. `tag` repeats — without `all: true` on the parse only
// the last would arrive, and a three-tag save would land as one.
const saved = await save(
'autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay'
)
expect(saved).toEqual([
{ Tag: 'limitsv2', Type: 1 },
{ Tag: 'roleplay', Type: 0, IsPrimaryGenre: true },
{ Tag: 'social', Type: 0 },
{ Tag: 'sports', Type: 0 },
])
// Idempotent — the same save twice is the same room. This is why a lone `tag` toggles
// but a `tag` in company does not: toggling here would clear the room on every save.
expect(
await save('autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay')
).toEqual(saved)
// `tag` is the COMPLETE user set: dropping one removes it. The auto tag is NOT the
// client's to send here and survives a save that never mentions it.
expect(await save('tag=roleplay&primaryGenreTag=roleplay')).toEqual([
{ Tag: 'limitsv2', Type: 1 },
{ Tag: 'roleplay', Type: 0, IsPrimaryGenre: true },
])
// autoTag is additive and repeatable, and re-categorises a tag the room already has
// rather than duplicating it — `tag` is the table's key, so there is one row per name.
expect(await save('autoTag=beta&autoTag=roleplay')).toEqual([
{ Tag: 'beta', Type: 1 },
{ Tag: 'limitsv2', Type: 1 },
// Was a Type 0 user tag; posting it as an auto tag moves its category, and the
// genre flag rides along with the row.
{ Tag: 'roleplay', Type: 1, IsPrimaryGenre: true },
])
// An autoTag alone is a valid request — it names no `tag`, and must not be refused
// for it.
const bare = await save('autoTag=limitsv2')
expect(bare.map((t) => t.Tag)).toContain('limitsv2')
// A save that names no user tags at all clears them, leaving the derived ones.
expect((await save('tag=&autoTag=limitsv2')).map((t) => t.Tag)).toEqual([
'beta',
'limitsv2',
'roleplay',
])
// Same gates as every other body.
expect(
(
await SELF.fetch(`${ORIGIN}/rooms/${ROOM}/tags`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'autoTag=limitsv2',
})
).status
).toBe(401)
await env.DB.batch([
env.DB.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(ROOM),
env.DB.prepare('DELETE FROM room WHERE room_id = ?1').bind(ROOM),
])
})
// Tags live in `room_tag`, not in the room blob (migration 0013). These pin the
// invariant that makes that safe: the table is the only copy, and the DTO is rebuilt
// from it on read.
it('stores tags in room_tag and never in the room blob', async () => {
await putForm('/rooms/2/tags', { tag: 'ghostly' }, '1')
// The blob must carry no `Tags` key at all — a second copy is what the table exists
// to eliminate, and it would only be kept current by the writes that go through
// toggleRoomTag.
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = 2').first<{
data: string
}>()
expect(JSON.parse(row!.data).Tags).toBeUndefined()
// The table has it, lowercased — `tag` is the lookup key the index is on.
const tagRow = await env.DB.prepare(
'SELECT tag, type FROM room_tag WHERE room_id = 2 AND tag = ?1'
)
.bind('ghostly')
.first<{ tag: string; type: number }>()
expect(tagRow).toEqual({ tag: 'ghostly', type: 0 })
// And the read re-attaches it, so the client sees an unchanged DTO.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Tags: Array<{ Tag: string; Type: number }>
}
expect(room.Tags).toContainEqual({ Tag: 'ghostly', Type: 0 })
// Toggle back off: the row goes, rather than lingering to answer `#ghostly` searches.
await putForm('/rooms/2/tags', { tag: 'ghostly' }, '1')
expect(
await env.DB.prepare('SELECT COUNT(*) AS n FROM room_tag WHERE room_id = 2 AND tag = ?1')
.bind('ghostly')
.first<{ n: number }>()
).toEqual({ n: 0 })
})
// D1 caps a statement at 100 bound parameters. The seeded database has 45 rooms, so
// every `IN (…)` list built per-room fitted and the cap went unnoticed until a real
// server crossed it: the hot feed attaches tags to EVERY room, so the bind list grew
// with the database and the query failed with "variable number must be between ?1 and
// ?100". This seeds past the cap so the feeds are exercised above it.
it('serves the feeds when there are more rooms than D1 allows bound parameters', async () => {
const FIRST = 20000
const COUNT = 150
for (let i = 0; i < COUNT; i++) {
const roomId = FIRST + i
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: roomId,
Name: `BulkRoom${i}`,
CreatorAccountId: 700,
IsDorm: false,
Accessibility: 1,
CreatedAt: '2026-05-01T00:00:00Z',
SubRooms: [],
})
)
.run()
// Every third one tagged, so the tag map is non-trivial above the cap too.
if (i % 3 === 0) {
await env.DB.prepare('INSERT INTO room_tag (room_id, tag, type) VALUES (?1, ?2, 0)')
.bind(roomId, 'bulky')
.run()
}
}
// The feed that failed: it reads every room, so it attaches tags for all of them.
const hot = await SELF.fetch(`${ORIGIN}/rooms/hot?take=5`)
expect(hot.status).toBe(200)
// Rooms owned by one account — this hydrates the WHOLE result set rather than a page,
// so its subroom and save reads run above the cap too. Those had the same latent bug.
const mine = await SELF.fetch(`${ORIGIN}/rooms/createdby/me`, { headers: await bearer('700') })
expect(mine.status).toBe(200)
expect(((await mine.json()) as unknown[]).length).toBe(COUNT)
// And the tag lookup still answers correctly above the cap.
const tagged = (await (
await SELF.fetch(`${ORIGIN}/rooms/search?query=%23bulky&take=200`)
).json()) as { Results: Array<{ RoomId: number }> }
expect(tagged.Results.length).toBe(Math.ceil(COUNT / 3))
// Clean up so the room counts other tests assert stay put.
await env.DB.prepare('DELETE FROM room WHERE room_id >= ?1').bind(FIRST).run()
await env.DB.prepare('DELETE FROM room_tag WHERE room_id >= ?1').bind(FIRST).run()
})
it("drops a deleted room's tag rows", async () => {
// Throwaway room owned by account 1, seeded directly the way the DELETE test above
// does, so this doesn't disturb the imported rooms.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 9600,
Name: 'TagCascadeRoom',
CreatorAccountId: 1,
IsDorm: false,
Accessibility: 1,
SubRooms: [],
})
)
.run()
await putForm('/rooms/9600/tags', { tag: 'doomed' }, '1')
const count = async () =>
(await env.DB.prepare('SELECT COUNT(*) AS n FROM room_tag WHERE room_id = 9600').first<{
n: number
}>())!.n
expect(await count()).toBe(1)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/9600`, { method: 'DELETE', headers: await bearer('1') }))
.status
).toBe(200)
// Otherwise the tag rows keep answering `#doomed` searches and category rows for a
// room nobody can open.
expect(await count()).toBe(0)
})
it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/name', { name: 'Whatever' })).status).toBe(401)
// Wrong owner / unknown room → Success:false envelopes.
expect(await bodyOf(await putForm('/rooms/2/name', { name: 'Whatever' }, '999'))).toMatchObject(
{
Success: false,
ErrorId: 'Rooms.NotOwner',
}
)
expect(
await bodyOf(await putForm('/rooms/99999/name', { name: 'Whatever' }, '1'))
).toMatchObject({
Success: false,
ErrorId: 'Rooms.DoesntExist',
})
// Empty name → Success:false.
expect(await bodyOf(await putForm('/rooms/2/name', { name: ' ' }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.InvalidName',
})
// A name already used by a different room (GoldenTrophy is room 12).
expect(
await bodyOf(await putForm('/rooms/2/name', { name: 'GoldenTrophy' }, '1'))
).toMatchObject({
Success: false,
ErrorId: 'Rooms.AlreadyExists',
Error: 'A room with that name already exists!',
})
// Owner renames to a free name, and it persists (findable by the new name).
const ok = await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1')
expect(await bodyOf(ok)).toMatchObject({ Success: true })
const room = (await (await SELF.fetch(`${ORIGIN}/rooms?name=RenamedCenter`)).json()) as {
RoomId: number
Name: string
}
expect(room.RoomId).toBe(2)
expect(room.Name).toBe('RenamedCenter')
// A rename must not resurrect the retired display name.
expect(room).not.toHaveProperty('FriendlyName')
})
/** The hub stub records every notifyPlayer call — see vitest.config.ts. */
type SentUpdate = { playerId: number; notificationType: string | number; data: Room }
const notifyHub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const resetNotifications = () => notifyHub().fetch('http://do/all', { method: 'DELETE' })
const sentNotifications = async (): Promise<SentUpdate[]> =>
(await (await notifyHub().fetch('http://do/all')).json()) as SentUpdate[]
it('a rename pushes a RoomUpdate to the owner carrying the new name', async () => {
// The rename answers a bare `{ Success }` with no room in it, so the push is the only
// thing that tells the client to redraw — without it the old name stays on screen.
await resetNotifications()
expect(
await bodyOf(await putForm('/rooms/2/name', { name: 'PushedRename' }, '1'))
).toMatchObject({ Success: true })
const sent = await sentNotifications()
expect(sent).toHaveLength(1)
// RoomUpdate, to the OWNER, carrying the room as it now stands.
expect(sent[0].playerId).toBe(1)
expect(sent[0].notificationType).toBe(NotificationType.SubscriptionUpdateRoom)
expect(sent[0].data).toMatchObject({ RoomId: 2, Name: 'PushedRename' })
expect(sent[0].data).not.toHaveProperty('FriendlyName')
// Put it back for the tests that read room 2 by name.
await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1')
})
it('a description edit and a tag toggle push a RoomUpdate too', async () => {
// Same reason for the description: its envelope carries no room either.
await resetNotifications()
await putForm('/rooms/2/description', { description: 'Pushed description' }, '1')
const afterDescription = await sentNotifications()
expect(afterDescription).toHaveLength(1)
expect(afterDescription[0].data).toMatchObject({
RoomId: 2,
Description: 'Pushed description',
})
// The tag toggle DOES answer the updated room, so its push is for the owner's other
// sessions rather than for the caller's own redraw.
await resetNotifications()
await putForm('/rooms/2/tags', { tag: 'pushedtag' }, '1')
const afterTag = await sentNotifications()
expect(afterTag).toHaveLength(1)
expect(afterTag[0].playerId).toBe(1)
const tags = afterTag[0].data.Tags as Array<{ Tag: string }>
expect(tags.map((t) => t.Tag)).toContain('pushedtag')
// Toggle it back off — the same call removes it.
await putForm('/rooms/2/tags', { tag: 'pushedtag' }, '1')
})
it('room_instance: create + read round-trips and hides JsonIgnore fields', async () => {
const created = await createRoomInstance(env.DB, {
ownerAccountId: 5,
roomId: 2,
subRoomId: 3,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 20,
isPrivate: true,
encryptVoiceChat: true,
})
// The DB assigns a sequential id, mapped to `roomInstanceId` in the DTO.
expect(created.roomInstanceId).toBeGreaterThan(0)
expect(created.roomId).toBe(2)
expect(created.isPrivate).toBe(true)
expect(created.EncryptVoiceChat).toBe(true) // PascalCase JSON key, per the reference
// Reads back identically; JsonIgnore columns are not in the DTO.
const fetched = await getRoomInstance(env.DB, created.roomInstanceId)
expect(fetched).toEqual(created)
expect('ownerAccountId' in (fetched as object)).toBe(false)
expect('dataBlob' in (fetched as object)).toBe(false)
expect('allowNewUsers' in (fetched as object)).toBe(false)
})
it('GET /photon_access_token 401s without a token', async () => {
expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401)
})
it('GET /photon_access_token returns permissions + presence instance', async () => {
// Seed the caller's presence so RoomInstanceId reflects their current instance.
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 777,
roomInstance: { roomInstanceId: 1000042 },
expiresAt: Math.floor(Date.now() / 1000) + 900,
})
)
.run()
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('777') })
expect(res.status).toBe(200)
const body = (await res.json()) as {
Permissions: Array<{ Permission: string; Role: number }>
PhotonAccessToken: string
RoomInstanceId: number | null
}
expect(body.Permissions.length).toBe(11)
expect(body.RoomInstanceId).toBe(1000042)
// A non-dev account does NOT get the global (Role 0) maker pen.
expect(body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toBe(
false
)
})
it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => {
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('888') })
expect(res.status).toBe(200)
expect(((await res.json()) as { RoomInstanceId: number | null }).RoomInstanceId).toBeNull()
})
it('GET /photon_access_token grants the global maker pen to dev accounts (1/2/3)', async () => {
for (const sub of ['1', '2', '3']) {
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer(sub) })
expect(res.status).toBe(200)
const body = (await res.json()) as {
Permissions: Array<{ Permission: string; Role: number; Override: boolean }>
}
// The global maker pen is prepended → first entry, Role 0, Override true.
expect(body.Permissions[0]).toMatchObject({
Permission: 'CAN_USE_MAKER_PEN',
Role: 0,
Override: true,
})
expect(body.Permissions.length).toBe(12)
}
})
it('interaction: defaults to false, cheer/favorite toggle and persist', async () => {
type Interaction = { Cheered: boolean; Favorited: boolean; LastVisitedAt: string }
const headers = await bearer('555')
const get = async () =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me`, { headers })
).json()) as Interaction
const put = async (action: 'cheer' | 'favorite') =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/${action}`, {
method: 'PUT',
headers,
})
).json()) as Interaction
// No row yet → both false.
expect(await get()).toMatchObject({ Cheered: false, Favorited: false })
// Cheer on, then favorite on.
expect(await put('cheer')).toMatchObject({ Cheered: true, Favorited: false })
expect(await put('favorite')).toMatchObject({ Cheered: true, Favorited: true })
// Persisted across a fresh GET.
expect(await get()).toMatchObject({ Cheered: true, Favorited: true })
// Toggling again flips back.
expect(await put('cheer')).toMatchObject({ Cheered: false, Favorited: true })
// Scoped per player — a different account starts fresh.
const other = await bearer('556')
const otherGet = (await (
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me`, { headers: other })
).json()) as Interaction
expect(otherGet).toMatchObject({ Cheered: false, Favorited: false })
})
it('room Stats aggregate cheers/favorites from the interaction table', async () => {
type Stats = {
CheerCount: number
FavoriteCount: number
VisitorCount: number
VisitCount: number
}
// Room 15 (CrimsonCauldron) is untouched by the other interaction tests.
const searched = async (): Promise<Stats> => {
const body = (await (
await SELF.fetch(`${ORIGIN}/rooms/search?query=crimsoncauldron`)
).json()) as { Results: Array<{ Stats: Stats }> }
return body.Results[0]!.Stats
}
const direct = async (): Promise<Stats> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/15`)).json()) as { Stats: Stats }).Stats
const interact = async (player: string, action: string, method: string) =>
SELF.fetch(`${ORIGIN}/rooms/15/interactionby/me/${action}`, {
method,
headers: await bearer(player),
})
// Nobody has interacted with it yet.
expect(await searched()).toEqual({
CheerCount: 0,
FavoriteCount: 0,
VisitorCount: 0,
VisitCount: 0,
})
// Two players cheer it; one of them also favorites it.
await interact('561', 'cheer', 'PUT')
await interact('562', 'cheer', 'PUT')
await interact('561', 'favorite', 'PUT')
// Both the search results and the room itself report the aggregate.
expect(await searched()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
expect(await direct()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
// Clearing a cheer decrements it. Visits are counted by the `match` worker on
// matchmake and nobody has entered this room, so those stay 0.
await interact('562', 'cheer', 'DELETE')
expect(await direct()).toEqual({
CheerCount: 1,
FavoriteCount: 1,
VisitorCount: 0,
VisitCount: 0,
})
// VisitCount is the `room.visits` column (what match bumps on each matchmake),
// served on every read of the room — here and in the search results — and it
// survives the cheer/favorite aggregation rather than being zeroed by it.
await env.DB.prepare('UPDATE room SET visits = 7 WHERE room_id = 15').run()
expect(await direct()).toEqual({
CheerCount: 1,
FavoriteCount: 1,
VisitorCount: 0,
VisitCount: 7,
})
expect(await searched()).toMatchObject({ VisitCount: 7 })
// A write to the room doesn't bake the count into the blob (nor reset it).
// Account 1 created room 15, so the description write is allowed.
const wrote = await SELF.fetch(`${ORIGIN}/rooms/15/description`, {
method: 'PUT',
headers: {
...(await bearer('1')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ description: 'counted' }).toString(),
})
expect(wrote.status).toBe(200)
const blob = await env.DB.prepare('SELECT data FROM room WHERE room_id = 15').first<{
data: string
}>()
expect((JSON.parse(blob!.data) as { Stats: Stats }).Stats.VisitCount).toBe(0)
expect(await direct()).toMatchObject({ VisitCount: 7 })
})
it('DELETE /rooms/:id/interactionby/me/cheer clears the cheer (auth-gated, idempotent)', async () => {
type Interaction = { Cheered: boolean; Favorited: boolean }
const headers = await bearer('557')
const del = () =>
SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/cheer`, { method: 'DELETE', headers })
// No token → 401.
expect(
(await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/cheer`, { method: 'DELETE' })).status
).toBe(401)
// Cheer + favorite on, then DELETE clears only the cheer (favorite untouched).
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/cheer`, { method: 'PUT', headers })
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'PUT', headers })
expect(await (await del()).json()).toMatchObject({ Cheered: false, Favorited: true })
// Idempotent — a second DELETE stays cleared.
expect(await (await del()).json()).toMatchObject({ Cheered: false, Favorited: true })
// Idempotent on a never-interacted room, and it doesn't create a visited row.
const fresh = await bearer('558')
const res = await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, {
method: 'DELETE',
headers: fresh,
})
expect(await res.json()).toMatchObject({ Cheered: false, Favorited: false })
const visited = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: fresh })
).json()) as unknown[]
expect(visited).toEqual([])
})
it('DELETE /rooms/:id/interactionby/me/favorite clears the favorite (auth-gated, idempotent)', async () => {
const headers = await bearer('559')
const del = () =>
SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'DELETE', headers })
// No token → 401.
expect(
(await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'DELETE' }))
.status
).toBe(401)
// Favorite + cheer on, then DELETE clears only the favorite (cheer untouched).
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'PUT', headers })
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/cheer`, { method: 'PUT', headers })
expect(await (await del()).json()).toMatchObject({ Cheered: true, Favorited: false })
// It drops out of the caller's favorited list.
const favs = (await (
await SELF.fetch(`${ORIGIN}/rooms/favoritedby/me`, { headers })
).json()) as unknown[]
expect(favs).toEqual([])
// Idempotent — a second DELETE stays cleared.
expect(await (await del()).json()).toMatchObject({ Cheered: true, Favorited: false })
// Idempotent on a never-interacted room, without creating a visited row.
const fresh = await bearer('560')
const res = await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/favorite`, {
method: 'DELETE',
headers: fresh,
})
expect(await res.json()).toMatchObject({ Cheered: false, Favorited: false })
const visited = (await (
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: fresh })
).json()) as unknown[]
expect(visited).toEqual([])
})
it('PUT /rooms/:id/subrooms/:sid/modify is auth-gated, owner-only, and persists subroom settings', async () => {
const fields = { name: 'MyCoolSubroom', accessibility: '1', maxPlayers: '20' }
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/subrooms/2/modify', fields)).status).toBe(401)
// Not the owner (room 2 is owned by account 1) → NotOwner.
expect(await bodyOf(await putForm('/rooms/2/subrooms/2/modify', fields, '999'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
// Unknown room → DoesntExist.
expect(
await bodyOf(await putForm('/rooms/99999/subrooms/2/modify', fields, '1'))
).toMatchObject({ Success: false, ErrorId: 'Rooms.DoesntExist' })
// Unknown subroom → DoesntExist.
expect(await bodyOf(await putForm('/rooms/2/subrooms/9999/modify', fields, '1'))).toMatchObject(
{ Success: false, ErrorId: 'Rooms.DoesntExist' }
)
// Empty name → InvalidName.
expect(
await bodyOf(await putForm('/rooms/2/subrooms/2/modify', { ...fields, name: ' ' }, '1'))
).toMatchObject({ Success: false, ErrorId: 'Rooms.InvalidName' })
// Owner updates the subroom → Success, and it persists on the subroom descriptor.
const ok = await putForm('/rooms/2/subrooms/2/modify', fields, '1')
expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({ Success: true })
const sub = (await subRoomOf(2, 2)) as unknown as {
Name: string
Accessibility: number
MaxPlayers: number
}
expect(sub).toMatchObject({ Name: 'MyCoolSubroom', Accessibility: 1, MaxPlayers: 20 })
})
it('PUT /rooms/:id/subrooms/:sid/accessibility takes the enum name the client sends', async () => {
const path = '/rooms/2/subrooms/2/accessibility'
const accessibilityOf = async () =>
((await subRoomOf(2, 2)) as unknown as { Accessibility: number }).Accessibility
// No token → 401.
expect((await putForm(path, { accessibility: 'Private' })).status).toBe(401)
// Not the owner (room 2 is owned by account 1) → failure envelope.
expect(await envOf(await putForm(path, { accessibility: 'Private' }, '999'))).toMatchObject({
success: false,
error: 'You are not the owner of this room!',
})
// Unknown room / unknown subroom → failure envelope.
expect(
await envOf(
await putForm('/rooms/99999/subrooms/2/accessibility', { accessibility: '0' }, '1')
)
).toMatchObject({ success: false })
expect(
await envOf(
await putForm('/rooms/2/subrooms/9999/accessibility', { accessibility: '0' }, '1')
)
).toMatchObject({ success: false })
// A value that names nothing in the enum → rejected, not silently stored.
expect(await envOf(await putForm(path, { accessibility: 'Nonsense' }, '1'))).toMatchObject({
success: false,
error: 'You must provide a valid accessibility!',
})
// The name form is what the live client sends.
const priv = await envOf(await putForm(path, { accessibility: 'Private' }, '1'))
expect(priv.success).toBe(true)
// The envelope carries the updated ROOM, so the client can re-render the subroom list.
expect(priv.value).toMatchObject({ RoomId: 2 })
expect(await accessibilityOf()).toBe(0)
// Case-insensitive, and the later enum members resolve too.
expect((await envOf(await putForm(path, { accessibility: 'dev_unlisted' }, '1'))).success).toBe(
true
)
expect(await accessibilityOf()).toBe(4)
// The ordinal still works.
expect((await envOf(await putForm(path, { accessibility: '1' }, '1'))).success).toBe(true)
expect(await accessibilityOf()).toBe(1)
})
// The permission table a room's creator saves on a subroom, and how it reaches the
// client: `PUT …/permissions` stores entries keyed by (Permission, Role), and
// `GET /photon_access_token` merges them over its defaults for whoever is standing in
// that subroom. Room 2 / subroom 2 is owned by account 1; account 743 is the visitor
// whose presence points at it.
describe('subroom permissions', () => {
type Permission = { Permission: string; Role: number; Override: boolean; Value: string }
const putPermissions = async (path: string, body: unknown, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
// Put a player in an instance of the given subroom, then read the permission table
// the client would apply when it spawns there.
const permissionsIn = async (accountId: number, subRoomId: number): Promise<Permission[]> => {
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId: 1000900 + subRoomId, roomId: 2, subRoomId },
expiresAt: Math.floor(Date.now() / 1000) + 900,
})
)
.run()
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
headers: await bearer(String(accountId)),
})
expect(res.status).toBe(200)
return ((await res.json()) as { Permissions: Permission[] }).Permissions
}
const entry = (list: Permission[], permission: string, role: number) =>
list.find((p) => p.Permission === permission && p.Role === role)
it('is auth-gated and creator-only', async () => {
const body = [
{ Permission: 'CAN_SAVE_INVENTIONS', Role: 30, Override: false, Type: 0, Value: 'True' },
]
// No token → 401.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body)).status).toBe(401)
// A valid token that isn't the room's creator → 403.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '999')).status).toBe(
403
)
// Not even a co-owner: account 2 holds Role 30 on the seeded rooms. Co-owners may
// build in a room but don't decide what a role may do.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '2')).status).toBe(403)
// Unknown room / unknown subroom → 404.
expect((await putPermissions('/rooms/99999/subrooms/2/permissions', body, '1')).status).toBe(
404
)
// A subroom id belonging to another room doesn't resolve either.
expect((await putPermissions('/rooms/2/subrooms/9999/permissions', body, '1')).status).toBe(
404
)
})
it('answers an empty 200 — the client reads no body', async () => {
const res = await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_SPAWN_INVENTIONS', Role: 30, Override: true, Type: 0, Value: 'True' }],
'1'
)
expect(res.status).toBe(200)
expect(await res.text()).toBe('')
})
it('a checked Override replaces the matching default in place', async () => {
const before = await permissionsIn(743, 2)
expect(before.length).toBe(11)
const at = before.findIndex((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 30)
// The default for this pair is an un-overridden grant.
expect(before[at]).toMatchObject({ Override: false, Value: 'True' })
expect(
(
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[
{
Permission: 'CAN_USE_MAKER_PEN',
Role: 30,
Override: true,
Type: 0,
Value: 'False',
},
],
'1'
)
).status
).toBe(200)
const after = await permissionsIn(743, 2)
// Replaced, not appended — and at the same index, so the table doesn't reshuffle.
expect(after.length).toBe(11)
expect(after[at]).toMatchObject({
Permission: 'CAN_USE_MAKER_PEN',
Role: 30,
Override: true,
Value: 'False',
})
// Re-sending the same (Permission, Role) updates that entry rather than adding one.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_USE_MAKER_PEN', Role: 30, Override: true, Type: 0, Value: 'True' }],
'1'
)
const changed = await permissionsIn(743, 2)
expect(changed.length).toBe(11)
expect(changed[at]).toMatchObject({ Override: true, Value: 'True' })
})
it('an unchecked Override erases the entry, back to the default', async () => {
const stored = async () =>
(await env.DB.prepare(
`SELECT COUNT(*) AS n FROM subroom_permission
WHERE sub_room_id = 2 AND permission = 'CAN_USE_MAKER_PEN' AND role = 30`
).first<{ n: number }>())!.n
// The previous test left this pair overridden.
expect(await stored()).toBe(1)
// `Override: false` means "fall back to the default" — the `Value` riding along is
// not stored, it's whatever the picker happened to show.
expect(
(
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[
{
Permission: 'CAN_USE_MAKER_PEN',
Role: 30,
Override: false,
Type: 0,
Value: 'True',
},
],
'1'
)
).status
).toBe(200)
// The row is gone, and the token serves the default for the pair again.
expect(await stored()).toBe(0)
const table = await permissionsIn(743, 2)
expect(table.length).toBe(11)
expect(entry(table, 'CAN_USE_MAKER_PEN', 30)).toMatchObject({
Override: false,
Value: 'True',
})
// Clearing a pair that was never overridden is a no-op, not an insert.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }],
'1'
)
expect((await permissionsIn(743, 2)).length).toBe(11)
})
it('appends a permission the defaults do not carry, and scopes it to its subroom', async () => {
// CAN_INVITE is in none of the defaults, so it lands as a new entry.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_INVITE', Role: 30, Override: true, Type: 0, Value: 'False' }],
'1'
)
const inSubRoom2 = await permissionsIn(744, 2)
expect(inSubRoom2.length).toBe(12)
expect(entry(inSubRoom2, 'CAN_INVITE', 30)).toMatchObject({
Override: true,
Value: 'False',
})
// A different subroom is untouched — the table is per-subroom, not per-room.
expect((await permissionsIn(744, 3)).length).toBe(11)
// And so is a player in no instance at all.
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(744).run()
const lobby = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
headers: await bearer('744'),
})
expect(((await lobby.json()) as { Permissions: Permission[] }).Permissions.length).toBe(11)
})
it('keeps a Value that isn’t True/False verbatim', async () => {
// Not every permission's UI is the True/False picker, so nothing interprets the
// string — it goes to the client exactly as the creator set it.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'MAX_SPAWNED_INVENTIONS', Role: 0, Override: true, Type: 0, Value: '25' }],
'1'
)
expect(entry(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS', 0)).toMatchObject({
Override: true,
Value: '25',
})
})
it('applies over the dev accounts’ global maker pen, without listing a pair twice', async () => {
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[
{ Permission: 'CAN_USE_MAKER_PEN', Role: 0, Override: true, Type: 0, Value: 'False' },
// The third sample body — a Role 0 grant the defaults already carry.
{
Permission: 'CAN_USE_DELETE_ALL_BUTTON',
Role: 0,
Override: true,
Type: 0,
Value: 'True',
},
],
'1'
)
// Account 3 is one of the hardcoded dev accounts, so it gets the global (Role 0)
// maker pen prepended — which this subroom then revokes. The merge runs last and
// replaces it in place, so the pair appears exactly ONCE: a table listing it twice
// with two values would leave which one applies up to the client.
const devTable = await permissionsIn(3, 2)
expect(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toEqual([
{ Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, Type: 0, Value: 'False' },
])
expect(entry(devTable, 'CAN_USE_DELETE_ALL_BUTTON', 0)).toMatchObject({ Value: 'True' })
// A normal player in the same subroom sees the same revocation.
expect(entry(await permissionsIn(745, 2), 'CAN_USE_MAKER_PEN', 0)).toMatchObject({
Value: 'False',
})
})
it('a cloned subroom inherits the source’s permission table', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
method: 'POST',
headers: await bearer('1'),
})
const room = (await res.json()) as { value: { SubRooms: Array<{ SubRoomId: number }> } }
const cloneId = Math.max(...room.value.SubRooms.map((s) => s.SubRoomId))
const inClone = await permissionsIn(746, cloneId)
expect(entry(inClone, 'CAN_INVITE', 30)).toMatchObject({ Value: 'False' })
expect(entry(inClone, 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ Value: 'False' })
})
})
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
const clone = async (roomId: number, subRoomId: number, sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
method: 'POST',
headers: sub ? await bearer(sub) : {},
})
type SubRoom = { SubRoomId: number; CreatorAccountId: number }
const envelope = async (res: Response) =>
(await res.json()) as {
success: boolean
error: string
value: { RoomId: number; SubRooms: SubRoom[] } | null
}
// No token → 401.
expect((await clone(2, 2)).status).toBe(401)
// Not the owner → success:false envelope.
expect((await envelope(await clone(2, 2, '999'))).success).toBe(false)
// Unknown subroom → success:false envelope.
expect((await envelope(await clone(2, 9999, '1'))).success).toBe(false)
const before = new Set(
(
(await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { SubRooms: SubRoom[] }
).SubRooms.map((s) => s.SubRoomId)
)
// Owner clones → success. `value` is the updated ROOM, not the new subroom, so the
// clone shows up as one extra entry in its re-attached SubRooms list.
const res = await clone(2, 2, '1')
expect(res.status).toBe(200)
const body = await envelope(res)
expect(body.success).toBe(true)
expect(body.value?.RoomId).toBe(2)
const added = body.value!.SubRooms.filter((s) => !before.has(s.SubRoomId))
expect(added).toHaveLength(1)
expect(added[0]!.CreatorAccountId).toBe(1)
// A fresh id, and fetchable as a subroom of the room.
expect(added[0]!.SubRoomId).not.toBe(2)
expect(await subRoomOf(2, added[0]!.SubRoomId)).toMatchObject({
SubRoomId: added[0]!.SubRoomId,
})
})
it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => {
// The old per-room `max(SubRoomId)+1` allocator would mint id 3 for room 2's clone —
// colliding with another room that already owns subroom 3. The subroom table's
// autoincrement mints an id above every existing subroom instead.
const maxBefore = (await env.DB.prepare('SELECT MAX(sub_room_id) AS m FROM subroom').first<{
m: number
}>())!.m
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
method: 'POST',
headers: await bearer('1'),
})
// `value` is the updated room; the clone is its highest-numbered subroom.
const body = (await res.json()) as {
value: { RoomId: number; SubRooms: Array<{ SubRoomId: number }> }
}
const cloned = Math.max(...body.value.SubRooms.map((s) => s.SubRoomId))
// Above every prior subroom id — a fresh global id, not a per-room collision.
expect(cloned).toBeGreaterThan(maxBefore)
expect(body.value.RoomId).toBe(2)
// The id is unique across the whole table (exactly one row owns it).
const dupes = (await env.DB.prepare('SELECT COUNT(*) AS n FROM subroom WHERE sub_room_id = ?1')
.bind(cloned)
.first<{ n: number }>())!.n
expect(dupes).toBe(1)
})
it('POST /rooms/:id/subrooms creates a new subroom (auth-gated, owner-only, fresh id)', async () => {
const create = async (roomId: number, name: string, sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms`, {
method: 'POST',
headers: {
...(sub ? await bearer(sub) : {}),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ name }).toString(),
})
type SubRoom = { SubRoomId: number; RoomId: number; Name: string; CreatorAccountId: number }
const envelope = async (res: Response) =>
(await res.json()) as {
success: boolean
// The whole room comes back on success (the client re-renders its subroom list).
value: { RoomId: number; SubRooms: SubRoom[] } | null
}
// No token → 401.
expect((await create(2, 'ffff')).status).toBe(401)
// Valid token but not the owner → success:false envelope.
expect((await envelope(await create(2, 'ffff', '999'))).success).toBe(false)
// Blank name → success:false envelope.
expect((await envelope(await create(2, ' ', '1'))).success).toBe(false)
// Unknown room → success:false envelope.
expect((await envelope(await create(99999, 'ffff', '1'))).success).toBe(false)
const maxBefore = (await env.DB.prepare('SELECT MAX(sub_room_id) AS m FROM subroom').first<{
m: number
}>())!.m
// Owner creates → success, and the returned room now embeds the new subroom: a fresh
// global SubRoomId owned by the caller, named, and fetchable.
const body = await envelope(await create(2, 'ffff', '1'))
expect(body.success).toBe(true)
expect(body.value?.RoomId).toBe(2)
const created = body.value?.SubRooms.find((s) => s.Name === 'ffff')
expect(created).toMatchObject({ RoomId: 2, Name: 'ffff', CreatorAccountId: 1 })
expect(created!.SubRoomId).toBeGreaterThan(maxBefore)
const fetched = (await subRoomOf(2, created!.SubRoomId)) as unknown as {
SubRoomId: number
Name: string
UnitySceneId: string
}
expect(fetched).toMatchObject({ SubRoomId: created?.SubRoomId, Name: 'ffff' })
// It inherits room 2's own existing (first) subroom scene.
const roomScene = (
JSON.parse(
(await env.DB.prepare(
'SELECT data FROM subroom WHERE room_id = 2 ORDER BY sub_room_id LIMIT 1'
).first<{ data: string }>())!.data
) as { UnitySceneId: string }
).UnitySceneId
expect(fetched.UnitySceneId).toBe(roomScene)
})
it('DELETE /rooms/:id/subrooms/:sid removes a subroom (auth-gated, owner-only, not the last)', async () => {
const del = async (roomId: number, subRoomId: number, sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}`, {
method: 'DELETE',
headers: sub ? await bearer(sub) : {},
})
type SubRoom = { SubRoomId: number; Name: string }
const envelope = async (res: Response) =>
(await res.json()) as { success: boolean; value: { SubRooms: SubRoom[] } | null }
// Add a subroom to room 2 (which already has others), and capture its id by name.
const created = (await (
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms`, {
method: 'POST',
headers: { ...(await bearer('1')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name: 'ToDelete' }).toString(),
})
).json()) as { value: { SubRooms: SubRoom[] } }
const newId = created.value.SubRooms.find((s) => s.Name === 'ToDelete')!.SubRoomId
// No token → 401. Not the owner → success:false. Unknown subroom → success:false.
expect((await del(2, newId)).status).toBe(401)
expect((await envelope(await del(2, newId, '999'))).success).toBe(false)
expect((await envelope(await del(2, 99999, '1'))).success).toBe(false)
// Owner deletes → success, and the subroom is gone from the returned room + not fetchable.
const body = await envelope(await del(2, newId, '1'))
expect(body.success).toBe(true)
expect(body.value?.SubRooms.some((s) => s.SubRoomId === newId)).toBe(false)
expect(await subRoomOf(2, newId)).toBeUndefined()
// A room's only subroom can't be deleted (would leave it with no scene). Seed a
// dedicated single-subroom room owned by account 1 to exercise the guard.
await seedRoomWithSubRooms(env.DB, {
RoomId: 700,
Name: 'SoloSubRoom',
CreatorAccountId: 1,
SubRooms: [{ SubRoomId: 900, UnitySceneId: 'x', MaxPlayers: 4 }],
})
expect((await envelope(await del(700, 900, '1'))).success).toBe(false)
// The lone subroom survives the refused delete.
expect(await subRoomOf(700, 900)).toBeDefined()
})
it('GET /rooms/:id/subrooms/:sid/saves pages the save history, newest first', async () => {
type Page = {
Results: Array<{ SubRoomId: number; SubRoomDataSaveId: number }>
TotalResults: number
TotalCount: number
}
const page = async (query: string) =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves${query}`, {
headers: await bearer('1'),
})
).json()) as Page
// Room 2's subroom is saved several times by the tests above — each save appended.
const all = await page('?unityAssetTarget=0&unityAssetVersion=1')
expect(all.Results.length).toBeGreaterThan(1)
expect(all.Results.every((s) => s.SubRoomId === 2)).toBe(true)
// Newest first: ids descend.
const ids = all.Results.map((s) => s.SubRoomDataSaveId)
expect([...ids].sort((a, b) => b - a)).toEqual(ids)
// Both spellings of the count, and they agree with the list.
expect(all.TotalResults).toBe(all.Results.length)
expect(all.TotalCount).toBe(all.TotalResults)
// skip/take actually page rather than being ignored.
const paged = await page('?skip=1&take=1')
expect(paged.Results).toHaveLength(1)
expect(paged.Results[0]!.SubRoomDataSaveId).toBe(ids[1])
expect(paged.TotalResults).toBe(all.TotalResults)
// A never-saved subroom pages empty rather than 404ing.
const empty = await SELF.fetch(`${ORIGIN}/rooms/3/subrooms/3/saves`, {
headers: await bearer('1'),
})
expect(await empty.json()).toEqual({ Results: [], TotalResults: 0, TotalCount: 0 })
// The list exposes unpublished saves, so it isn't public: no token → 401, and a
// valid token from someone who is neither the creator nor in the room → 403. Account
// 2 is a co-owner (Role 30 on the seeded rooms) and is refused too — holding a role
// grants nothing here; being in the room does (see below).
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`)).status).toBe(401)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status
).toBe(403)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('2') }))
.status
).toBe(403)
// …but a player standing IN the room reads it: the client resolves which version to
// load from this list, so a visitor who can't read it can't load the instance.
await putInRoom(999, 2)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status
).toBe(200)
// Presence in a DIFFERENT room is not presence in this one.
await putInRoom(999, 5)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status
).toBe(403)
// And the grant lasts only as long as the presence does — an expired row reads as
// absent, so the visitor is refused again the moment they leave.
await putInRoom(999, 2, { expired: true })
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status
).toBe(403)
await clearPresence(999)
})
it('GET /rooms/:id/subrooms/:sid/saves/no_unity_assets lists the same history, lighter', async () => {
const get = async (path: string, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, sub === undefined ? {} : { headers: await bearer(sub) })
const light = '/rooms/2/subrooms/2/saves/no_unity_assets'
const res = await get(light, '1')
expect(res.status).toBe(200)
const page = (await res.json()) as {
Results: Array<Record<string, unknown>>
TotalResults: number
TotalCount: number
}
// The same history the full list serves — same rows, same order, same counts.
const full = (await (await get('/rooms/2/subrooms/2/saves', '1')).json()) as {
Results: Array<{ SubRoomDataSaveId: number; DataBlob: string }>
TotalResults: number
}
expect(page.TotalResults).toBe(full.TotalResults)
expect(page.TotalCount).toBe(page.TotalResults)
expect(page.Results.map((r) => r.SubRoomDataSaveId)).toEqual(
full.Results.map((r) => r.SubRoomDataSaveId)
)
// The lighter row: the Unity-asset PAYLOADS are gone (and `Tags` with them), the asset
// IDs stay, and `UnityAssetId` is present-and-null rather than omitted.
const row = page.Results[0]!
expect(Object.keys(row)).toEqual([
'SubRoomDataSaveId',
'SubRoomId',
'UnityAssetId',
'ReferencedUnityAssetIds',
'DataBlob',
'DataBlobHash',
'PersistenceVersion',
'OMVersion',
'SavedByAccountId',
'SavedOnPlatform',
'SavedOnDeviceClass',
'Description',
'ModerationState',
'CreatedAt',
'UgcSubVersion',
])
expect(row.UnityAssetId).toBe(null)
expect(row.ReferencedUnityAssetIds).toEqual([])
expect(row.SubRoomId).toBe(2)
expect(row.DataBlob).toBe(full.Results[0]!.DataBlob)
// skip/take page it the same way.
const paged = (await (await get(`${light}?skip=1&take=1`, '1')).json()) as {
Results: Array<{ SubRoomDataSaveId: number }>
TotalResults: number
}
expect(paged.Results).toHaveLength(1)
expect(paged.Results[0]!.SubRoomDataSaveId).toBe(full.Results[1]!.SubRoomDataSaveId)
expect(paged.TotalResults).toBe(full.TotalResults)
// A never-saved subroom pages empty rather than 404ing, as the full list does.
expect(await (await get('/rooms/3/subrooms/3/saves/no_unity_assets', '1')).json()).toEqual({
Results: [],
TotalResults: 0,
TotalCount: 0,
})
// Same gate as the list it mirrors — it exposes the same unpublished saves.
expect((await get(light)).status).toBe(401)
expect((await get(light, '999')).status).toBe(403)
expect((await get(light, '2')).status).toBe(403)
await putInRoom(999, 2)
expect((await get(light, '999')).status).toBe(200)
await clearPresence(999)
expect((await get(light, '999')).status).toBe(403)
})
it('GET /rooms/:id/subrooms/:sid/saves/:saveId is the detail behind a history row', async () => {
const get = async (path: string, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, sub === undefined ? {} : { headers: await bearer(sub) })
// Pick a real save off the history the previous test paged.
const list = (await (await get('/rooms/2/subrooms/2/saves', '1')).json()) as {
Results: Array<{ SubRoomDataSaveId: number; DataBlob: string; Description: string }>
}
const row = list.Results[0]!
const res = await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '1')
expect(res.status).toBe(200)
// The camelCase projection the room save returns — NOT the PascalCase row the list
// serves. Same field set, exactly: no persistence/OM/UGC versions, no asset arrays.
expect(await res.json()).toEqual({
subRoomDataSaveId: row.SubRoomDataSaveId,
subRoomId: 2,
unityAssetId: null,
unityAsset: null,
unityAssetHash: null,
dataBlob: row.DataBlob,
dataBlobHash: null,
savedByAccountId: expect.any(Number),
savedOnPlatform: 0,
savedOnDeviceClass: 0,
description: row.Description,
createdAt: expect.any(String),
})
// Unknown save, and a save that exists but belongs to ANOTHER subroom (ids are
// global, so an unscoped lookup would happily resolve this one) — both 404.
expect((await get('/rooms/2/subrooms/2/saves/99999', '1')).status).toBe(404)
const foreign = (
(await subRoomOf(5, 5)) as unknown as { CurrentSave: { SubRoomDataSaveId: number } }
).CurrentSave.SubRoomDataSaveId
expect((await get(`/rooms/2/subrooms/2/saves/${foreign}`, '1')).status).toBe(404)
// …and it does resolve on its own subroom, so the 404 above is the scoping, not a
// missing row.
expect((await get(`/rooms/5/subrooms/5/saves/${foreign}`, '1')).status).toBe(200)
// Unknown room or subroom is a 404 too (the LIST answers an empty page instead).
expect((await get('/rooms/99999/subrooms/2/saves/1', '1')).status).toBe(404)
expect((await get('/rooms/2/subrooms/99999/saves/1', '1')).status).toBe(404)
// Same gate as the list it details: 401 unauthed, 403 for someone who is neither the
// creator nor in the room (a co-owner included) — it reads unpublished saves.
const detail = `/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`
expect((await get(detail)).status).toBe(401)
expect((await get(detail, '999')).status).toBe(403)
expect((await get(detail, '2')).status).toBe(403)
// A player standing in the room reads it, for as long as they're there.
await putInRoom(999, 2)
expect((await get(detail, '999')).status).toBe(200)
await clearPresence(999)
expect((await get(detail, '999')).status).toBe(403)
})
it('GET /openapi.json documents every route', async () => {
const res = await SELF.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}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /rooms/{roomId}',
'DELETE /rooms/{roomId}/bans/bulk',
'DELETE /rooms/{roomId}/bans/{playerId}',
'DELETE /rooms/{roomId}/interactionby/me/cheer',
'DELETE /rooms/{roomId}/interactionby/me/favorite',
'DELETE /rooms/{roomId}/leaderboards/{leaderboardId}',
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
'GET /',
'GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned',
'GET /dormroom/me',
'GET /featuredrooms/current',
'GET /photon_access_token',
'GET /publishState/configs',
'GET /rooms',
'GET /rooms/autocomplete_search',
'GET /rooms/base',
'GET /rooms/bulk',
'GET /rooms/carousel/rising',
'GET /rooms/contributedby/me',
'GET /rooms/createdby/me',
'GET /rooms/curated_playlists',
'GET /rooms/favoritedby/me',
'GET /rooms/hot',
'GET /rooms/ownedby/me',
'GET /rooms/ownedby/{accountId}',
'GET /rooms/recommendations',
'GET /rooms/search',
'GET /rooms/visitedby/me',
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/bans/history',
'GET /rooms/{roomId}/bans/{playerId}/isBanned',
'GET /rooms/{roomId}/experience',
'GET /rooms/{roomId}/experience/player',
'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/roles',
'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
'GET /roomserver/rooms/createdby/me',
'GET /showcase/{playerId}',
'POST /rooms/bulk',
'POST /rooms/{roomId}/bans',
'POST /rooms/{roomId}/clone',
'POST /rooms/{roomId}/leaderboards/{leaderboardId}',
'POST /rooms/{roomId}/subrooms',
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save',
'PUT /rooms/{roomId}/accessibility',
'PUT /rooms/{roomId}/cloning',
'PUT /rooms/{roomId}/creator',
'PUT /rooms/{roomId}/description',
'PUT /rooms/{roomId}/image',
'PUT /rooms/{roomId}/interactionby/me/cheer',
'PUT /rooms/{roomId}/interactionby/me/favorite',
'PUT /rooms/{roomId}/loadscreen',
'PUT /rooms/{roomId}/name',
'PUT /rooms/{roomId}/restrictions',
'PUT /rooms/{roomId}/roles/{accountId}',
'PUT /rooms/{roomId}/roles/{accountId}/invite',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
'PUT /rooms/{roomId}/tags',
'PUT /rooms/{roomId}/warning',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
// Room and subroom names take letters, digits and underscores, at most 32 (see
// `roomNameRejection` in @repo/domain — usernames are held to the narrower rule, with no
// underscore). All four routes that take a player-supplied name enforce it, and each
// keeps its OWN refusal shape: the create
// paths answer the lowercase `{ success, error, value }` envelope, the two settings
// routes answer `{ Success, ErrorId, Error }` with the same `Rooms.InvalidName` id they
// already used for an empty name. The client keys off those, so the rule had to fit the
// existing shapes rather than introduce a fifth one.
//
// Names the SERVER generates are exempt on purpose — a dorm is `@<username>'s Dorm`,
// which this rule would reject. That's why the check lives in the handlers.
describe('room name validation', () => {
const bad = ['My Room', 'punct!', 'a'.repeat(33)]
const post = async (path: string, fields: Record<string, string>, sub: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
const put = async (path: string, fields: Record<string, string>, sub: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
it('refuses a bad name when a player clones a room into existence', async () => {
for (const name of bad) {
const res = await post('/rooms/2/clone', { name }, '1')
const body = (await res.json()) as { success: boolean; error: string; value: unknown }
expect(body.success, name).toBe(false)
expect(body.error).toMatch(/letters, numbers and underscores|at most 32 characters/)
expect(body.value).toBeNull()
}
})
it('refuses a bad name on rename, with the id the client already handles', async () => {
for (const name of bad) {
const res = await put('/rooms/2/name', { name }, '1')
const body = (await res.json()) as { Success: boolean; ErrorId: string; Error: string }
expect(body.Success, name).toBe(false)
expect(body.ErrorId).toBe('Rooms.InvalidName')
expect(body.Error).toMatch(/letters, numbers and underscores|at most 32 characters/)
}
// Unchanged: the refusals above never reached the write.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
expect(room.Name).not.toMatch(/[^A-Za-z0-9_]/)
})
it('refuses a bad name when creating or modifying a subroom', async () => {
for (const name of bad) {
const created = await post('/rooms/2/subrooms', { name }, '1')
const env1 = (await created.json()) as { success: boolean; error: string }
expect(env1.success, name).toBe(false)
expect(env1.error).toMatch(/letters, numbers and underscores|at most 32 characters/)
const modified = await put(
'/rooms/2/subrooms/2/modify',
{ name, accessibility: '1', maxPlayers: '20' },
'1'
)
const res2 = (await modified.json()) as { Success: boolean; ErrorId: string }
expect(res2.Success, name).toBe(false)
expect(res2.ErrorId).toBe('Rooms.InvalidName')
}
})
it('accepts a 32-character name, and an underscore where a space is refused', async () => {
for (const name of ['a'.repeat(32), 'Laser_Tag']) {
const res = await post('/rooms/2/subrooms', { name }, '1')
const body = (await res.json()) as {
success: boolean
value: { SubRooms: Array<{ Name: string }> }
}
expect(body.success, name).toBe(true)
expect(body.value.SubRooms.some((s) => s.Name === name)).toBe(true)
}
})
})
describe('sweep cron', () => {
async function castBallot(voterId: number, agoMs: number, init = 0): Promise<void> {
await env.DB.prepare(
`INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at, init)
VALUES (1, 205, 1, ?1, ?2, ?3)`
)
.bind(voterId, new Date(Date.now() - agoMs).toISOString(), init)
.run()
}
async function voterIds(): Promise<number[]> {
const rows = await env.DB.prepare('SELECT voter_id FROM room_vote ORDER BY voter_id').all<{
voter_id: number
}>()
return rows.results.map((r) => r.voter_id)
}
it('deletes ballots older than 5 minutes and keeps the rest', async () => {
await env.DB.prepare('DELETE FROM room_vote').run()
// A vote called long ago, and one that just closed: both dead weight.
await castBallot(1, 6 * 60_000, 1)
await castBallot(2, 5 * 60_000 + 1_000)
// A caller still inside their 5-minute cooldown, an open vote, and its answer.
await castBallot(3, 4 * 60_000 + 30_000, 1)
await castBallot(4, 30_000, 1)
await castBallot(5, 0)
// Driven through the module's own export rather than the `exports` proxy — a
// ScheduledController can't cross the isolate boundary the proxy serializes over.
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await voterIds()).toEqual([3, 4, 5])
})
async function sendInvite(fromPlayerId: number, agoSeconds: number): Promise<void> {
await env.DB.prepare(
`INSERT INTO room_invite (from_player_id, to_player_id, room_id, created_at)
VALUES (?1, 790, 823, ?2)`
)
.bind(fromPlayerId, Math.floor(Date.now() / 1000) - agoSeconds)
.run()
}
async function inviterIds(): Promise<number[]> {
const rows = await env.DB.prepare(
'SELECT from_player_id FROM room_invite ORDER BY from_player_id'
).all<{ from_player_id: number }>()
return rows.results.map((r) => r.from_player_id)
}
it('deletes invites older than 5 minutes and keeps the rest', async () => {
await env.DB.prepare('DELETE FROM room_invite').run()
await sendInvite(1, 6 * 60)
await sendInvite(2, 5 * 60 + 1)
await sendInvite(3, 4 * 60 + 30)
await sendInvite(4, 0)
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await inviterIds()).toEqual([3, 4])
})
it('is a no-op on empty tables', async () => {
await env.DB.prepare('DELETE FROM room_vote').run()
await env.DB.prepare('DELETE FROM room_invite').run()
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await voterIds()).toEqual([])
expect(await inviterIds()).toEqual([])
})
})