import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'

import '../../rooms.app'

import {
	createRoomInstance,
	getRoomInstance,
	PRESENCE_SCHEMA_DDL,
	ROOM_INSTANCE_SCHEMA_DDL,
	ROOM_SCHEMA_DDL,
	seedRoomWithSubRooms,
	SUBROOM_SCHEMA_DDL,
} from '@repo/domain'

import { NotificationType } from '../../../../notify/src/notification-types'
import importRooms from '../../../static/ImportRooms.json'

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()
	// 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 the one that can't be null: the client
	// labels the room from it.
	it('GET /rooms/:id carries BoostCount, CurrentSnapshotId, FriendlyName and CCU', 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('FriendlyName', body.Name)
		expect(body).toHaveProperty('CCU', null)
	})

	// 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([])
	})

	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
					Accessibility: number
					Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }>
				} | null
			}

		// 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)
		// 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
		}
		expect(fetched.Name).toBe('MyMakerClone')

		// 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('PUT /rooms/:id/roles/:accountId is auth-gated, owner/co-owner-only, and persists', async () => {
		const rolesOf = async (): Promise<Array<{ AccountId: number; Role: number }>> => {
			const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
				Roles?: Array<{ AccountId: number; Role: number }>
			}
			return room.Roles ?? []
		}

		// No token → 401 (auth gate).
		expect((await putForm('/rooms/2/roles/5', { role: '20' })).status).toBe(401)
		// A valid token but no role on the room (RecCenter is owned by account 1, with
		// account 2 as co-owner) → 403.
		expect((await putForm('/rooms/2/roles/5', { role: '20' }, '999')).status).toBe(403)
		// Unknown room → failure envelope.
		expect(await envOf(await putForm('/rooms/99999/roles/5', { role: '20' }, '1'))).toMatchObject({
			success: false,
			error: 'This room does not exist!',
		})
		// Non-numeric role → failure envelope.
		expect(await envOf(await putForm('/rooms/2/roles/5', { role: 'nope' }, '1'))).toMatchObject({
			success: false,
		})

		// Owner sets account 5's role to 20, adding a new Roles entry that persists. The
		// success envelope carries the updated room as `value`.
		const ok = await putForm('/rooms/2/roles/5', { role: '20' }, '1')
		expect(ok.status).toBe(200)
		const okBody = await envOf(ok)
		expect(okBody).toMatchObject({ success: true, error: '' })
		expect(okBody.value?.Roles as Array<{ AccountId: number; Role: number }>).toContainEqual(
			expect.objectContaining({ AccountId: 5, Role: 20 })
		)
		expect(await rolesOf()).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 20 }))

		// The co-owner (account 2, Role 30) may also change it — updating the existing
		// entry in place rather than adding a duplicate.
		const byCoOwner = await putForm('/rooms/2/roles/5', { role: '10' }, '2')
		expect(byCoOwner.status).toBe(200)
		const roles = await rolesOf()
		expect(roles.filter((r) => r.AccountId === 5)).toHaveLength(1)
		expect(roles).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 10 }))
		// The seeded co-owner (account 2) is left intact.
		expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 }))
	})

	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 /rooms/:id/bans kicks the banned player', async () => {
		type Sent = { playerId: number; 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 hub().fetch('http://do/all', { method: 'DELETE' })
		expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)

		// A ModerationKick (id 22) to the BANNED player, not the caller — it ejects them
		// from the instance they're in now; the row keeps them out of future matchmakes.
		// Asserted against the enum rather than a literal: the ids are notify's to change.
		expect(await sentSince()).toEqual([
			{
				playerId: 207,
				notificationType: NotificationType.ModerationKick,
				// The client's moderation payload, camelCase, in wire order.
				data: {
					reportCategory: -1, // Moderator — a person acted, not the system
					duration: 0, // a room ban has no expiry
					gameSessionId: 0,
					// The host ejected them (as opposed to a room vote-kick, which doesn't
					// exist yet). Account 1 owns RecCenter, so it hosts it.
					isHostKick: true,
					message: `You have been banned from ${Name}.`,
					playerIdReporter: 1,
					isBan: true,
					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 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({
			playerId: 208,
			data: { isHostKick: false, playerIdReporter: 999 },
		})
	})

	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('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
			FriendlyName: string
		}
		expect(room.RoomId).toBe(2)
		// The DISPLAY name follows the rename. It is otherwise only defaulted to `Name` on
		// read, so a room that had ever stored one would keep labelling itself with the old
		// name while every name-keyed lookup used the new one.
		expect(room.FriendlyName).toBe('RenamedCenter')
	})

	/** 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 — both names.
		expect(sent[0].playerId).toBe(1)
		expect(sent[0].notificationType).toBe(NotificationType.SubscriptionUpdateRoom)
		expect(sent[0].data).toMatchObject({
			RoomId: 2,
			Name: 'PushedRename',
			FriendlyName: 'PushedRename',
		})

		// 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/{playerId}',
			'DELETE /rooms/{roomId}/interactionby/me/cheer',
			'DELETE /rooms/{roomId}/interactionby/me/favorite',
			'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/{playerId}/isBanned',
			'GET /rooms/{roomId}/experience',
			'GET /rooms/{roomId}/experience/player',
			'GET /rooms/{roomId}/interactionby/me',
			'GET /rooms/{roomId}/playerdata/me',
			'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',
			'POST /rooms/bulk',
			'POST /rooms/{roomId}/bans',
			'POST /rooms/{roomId}/clone',
			'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}/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}/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)
		}
	})
})
