From 2cc01a59127be744f659bdbcb3fdcfa00296aa37 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 30 Jun 2026 17:37:25 -0400 Subject: [PATCH] implement stubs for /similar, visited, favroited, ownedby...implement clone --- apps/rooms/src/rooms-db.ts | 203 ++++++++++++++- apps/rooms/src/rooms.app.ts | 143 +++++++++-- apps/rooms/src/test/integration/api.test.ts | 260 +++++++++++++++++++- 3 files changed, 577 insertions(+), 29 deletions(-) diff --git a/apps/rooms/src/rooms-db.ts b/apps/rooms/src/rooms-db.ts index fa3efa3..cf94050 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/apps/rooms/src/rooms-db.ts @@ -37,6 +37,47 @@ export const SCHEMA_DDL: string[] = [ /** A stored room — the parsed JSON blob (full client-facing room response). */ export type Room = Record +/** + * Clone an existing room into a new one owned by `accountId`. Copies the source + * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given + * name, and the new owner; the `base` template tag is dropped so user clones + * aren't themselves listed as base rooms. Returns the new room, or null when the + * source isn't in D1 or disallows cloning. + */ +export async function cloneRoom( + db: D1Database, + sourceRoomId: number, + name: string, + accountId: number +): Promise { + const source = await getRoomById(db, sourceRoomId) + if (!source || source.CloningAllowed === false) return null + + const row = await db + .prepare('SELECT MAX(room_id) AS maxId FROM rooms') + .first<{ maxId: number | null }>() + const newRoomId = (row?.maxId ?? 0) + 1 + + const tags = Array.isArray(source.Tags) + ? (source.Tags as Array>).filter( + (t) => String(t?.Tag).toLowerCase() !== 'base' + ) + : source.Tags + + const cloned: Room = { + ...source, + RoomId: newRoomId, + Name: name, + CreatorAccountId: accountId, + IsDorm: false, + Tags: tags, + CreatedAt: new Date().toISOString(), + } + + await db.prepare('INSERT INTO rooms (data) VALUES (?1)').bind(JSON.stringify(cloned)).run() + return cloned +} + interface RoomRow { data: string } @@ -81,6 +122,56 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom return parseAll(results) } +/** + * Rooms the player has favorited (interaction.favorited = 1), most recently + * interacted first. Joins the `interaction` table to `rooms`, so a favorited room + * no longer in D1 is simply absent. Paginated via skip/take; returns a bare array + * of rooms (the client's room-source loaders expect a plain list). + */ +export async function getFavoritedRooms( + db: D1Database, + playerId: number, + skip: number, + take: number +): Promise { + const { results } = await db + .prepare( + `SELECT r.data AS data + FROM interaction i + JOIN rooms r ON r.room_id = i.room_id + WHERE i.player_id = ?1 AND i.favorited = 1 + ORDER BY i.last_visited_at DESC` + ) + .bind(playerId) + .all() + return parseAll(results).slice(skip, skip + take) +} + +/** + * Rooms the player has visited (an interaction row with a `last_visited_at`), + * most recent first. Like favorites, it joins `interaction` to `rooms`, so a + * visited room no longer in D1 is simply absent. Paginated via skip/take; returns + * a bare array of rooms (the client's room-source loaders expect a plain list). + */ +export async function getVisitedRooms( + db: D1Database, + playerId: number, + skip: number, + take: number +): Promise { + const { results } = await db + .prepare( + `SELECT r.data AS data + FROM interaction i + JOIN rooms r ON r.room_id = i.room_id + WHERE i.player_id = ?1 AND i.last_visited_at IS NOT NULL + ORDER BY i.last_visited_at DESC` + ) + .bind(playerId) + .all() + return parseAll(results).slice(skip, skip + take) +} + /** A player's interaction state with a room. */ export interface Interaction { Cheered: boolean @@ -161,14 +252,19 @@ const TAG_ALIASES: Record = { recroomoriginal: ['rro'], } +/** A room's tag names, lowercased (empty when it has no Tags array). */ +function roomTags(room: Room): string[] { + const tags = room.Tags + if (!Array.isArray(tags)) return [] + return tags + .map((t) => (t as Record | null)?.Tag) + .filter((v): v is string => typeof v === 'string') + .map((v) => v.toLowerCase()) +} + /** True if the room carries any of the given (lowercased) tags. */ function roomHasAnyTag(room: Room, tags: Set): boolean { - const roomTags = room.Tags - if (!Array.isArray(roomTags)) return false - return roomTags.some((t) => { - const value = (t as Record | null)?.Tag - return typeof value === 'string' && tags.has(value.toLowerCase()) - }) + return roomTags(room).some((t) => tags.has(t)) } /** @@ -202,3 +298,98 @@ export async function searchRooms( return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } } + +/** Engagement score used to order the hot feed (cheers weigh most, then favorites). */ +function hotScore(room: Room): number { + const stats = room.Stats as Record | null | undefined + const n = (v: unknown): number => (typeof v === 'number' ? v : 0) + return n(stats?.CheerCount) * 3 + n(stats?.FavoriteCount) * 2 + n(stats?.VisitorCount) +} + +/** + * The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered + * by engagement and optionally filtered to a single `tag` (with the same aliases + * as search). Paginated via skip/take; returns `{ Results, TotalResults }` like + * search. Ties (and the all-zero seed data) fall back to RoomId order so paging + * is stable. The dataset is small, so this filters/sorts in memory rather than + * in SQL. + */ +export async function getHotRooms( + db: D1Database, + tag: string, + skip: number, + take: number +): Promise<{ Results: Room[]; TotalResults: number }> { + const { results } = await db.prepare('SELECT data FROM rooms').all() + let rooms = parseAll(results).filter( + (r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true + ) + + const t = tag.trim().toLowerCase() + if (t !== '') { + const accepted = new Set([t, ...(TAG_ALIASES[t] ?? [])]) + rooms = rooms.filter((r) => roomHasAnyTag(r, accepted)) + } + + const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) + rooms.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b)) + return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } +} + +/** + * Rooms similar to a target room: public, non-dorm rooms (excluding the target) + * that share at least one tag with it, ranked by shared-tag count then + * engagement. Returns a bare array; empty if the target isn't in D1 or is + * untagged. Paginated via skip/take. Small dataset, so done in memory. + */ +export async function getSimilarRooms( + db: D1Database, + roomId: number, + skip: number, + take: number +): Promise { + const target = await getRoomById(db, roomId) + if (!target) return [] + const targetTags = new Set(roomTags(target)) + if (targetTags.size === 0) return [] + + const { results } = await db.prepare('SELECT data FROM rooms').all() + const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length + const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) + + const scored = parseAll(results) + .filter( + (r) => + roomIdOf(r) !== roomId && + r.IsDorm !== true && + r.Accessibility === 1 && + r.ExcludeFromLists !== true + ) + .map((room) => ({ room, shared: sharedCount(room) })) + .filter((x) => x.shared > 0) + + scored.sort( + (a, b) => + b.shared - a.shared || + hotScore(b.room) - hotScore(a.room) || + roomIdOf(a.room) - roomIdOf(b.room) + ) + return scored.slice(skip, skip + take).map((x) => x.room) +} + +/** + * "Base" rooms — the template rooms tagged `base` that the client offers as + * starting points when creating a room. Unlike the public feeds these are + * returned regardless of accessibility (most base rooms aren't publicly listed). + * Ordered by RoomId for stable paging. Paginated via skip/take; returns a bare + * array. Small dataset, so done in memory. + */ +export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise { + const { results } = await db.prepare('SELECT data FROM rooms').all() + const base = new Set(['base']) + const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) + return parseAll(results) + .filter((r) => roomHasAnyTag(r, base)) + .sort((a, b) => roomIdOf(a) - roomIdOf(b)) + .slice(skip, skip + take) +} diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 5ea3399..e789df0 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -5,11 +5,17 @@ import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from './jwt' import { + cloneRoom, + getBaseRooms, + getFavoritedRooms, + getHotRooms, getInteraction, getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds, + getSimilarRooms, + getVisitedRooms, searchRooms, toggleCheer, toggleFavorite, @@ -72,16 +78,30 @@ function photonAccessToken() { } } -/** The account whose owned rooms to return: the Bearer token's `sub`, falling - * back to account 1 (the stub player) when there's no valid token. */ -async function ownerId(c: Context): Promise { +/** The Bearer token's account id (`sub`), or null when there's no valid token. */ +async function authedAccountId(c: Context): Promise { const authHeader = c.req.header('Authorization') ?? '' - if (authHeader.toLowerCase().startsWith('bearer ')) { - const sub = await validateAndGetAccountId(authHeader.slice('Bearer '.length)) - const id = sub ? Number.parseInt(sub, 10) : Number.NaN - if (!Number.isNaN(id)) return id - } - return 1 + if (!authHeader.toLowerCase().startsWith('bearer ')) return null + const sub = await validateAndGetAccountId(authHeader.slice('Bearer '.length)) + const id = sub ? Number.parseInt(sub, 10) : Number.NaN + return Number.isNaN(id) ? null : id +} + +/** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */ +function unauthorized(c: Context) { + return c.json({ error: 'Unauthorized' }, 401) +} + +/** Client envelope for room clone results: `{ success, error, value }`. */ +function cloneResult(c: Context, value: unknown, error = '') { + return c.json({ success: error === '', error, value }) +} + +/** Rooms created/owned by the authed caller (shared by the createdby/ownedby routes). */ +async function ownedRooms(c: Context) { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + return c.json(await getRoomsByCreator(c.env.DB, accountId)) } const app = new Hono() @@ -127,6 +147,25 @@ const app = new Hono() return c.json(await searchRooms(c.env.DB, query, skip, take)) }) + // "Hot" rooms feed — public, non-dorm rooms ordered by engagement, optionally + // filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take + // defaults to 100). Returns `{ Results, TotalResults }` like search. + .get('/rooms/hot', async (c) => { + const tag = c.req.query('tag') ?? '' + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getHotRooms(c.env.DB, tag, skip, take)) + }) + + // "Base" rooms — template rooms (tagged `base`) the client offers when creating + // a room. Returned regardless of accessibility. Paginated via skip/take (take + // defaults to 100). Returns a bare array. + .get('/rooms/base', async (c) => { + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getBaseRooms(c.env.DB, skip, take)) + }) + // Bulk room lookup by `id` or `name` — returns an array of matched rooms (the // client calls this bare on the rooms host). Rooms not in D1 are simply absent // from the result; the client treats an empty result as NoSuchRoom. @@ -144,42 +183,106 @@ const app = new Hono() }) // Rooms created/owned by the caller (their dorm). The client calls all three. - .get('/roomserver/rooms/createdby/me', async (c) => - c.json(await getRoomsByCreator(c.env.DB, await ownerId(c))) - ) - .get('/rooms/ownedby/me', async (c) => c.json(await getRoomsByCreator(c.env.DB, await ownerId(c)))) - .get('/rooms/createdby/me', async (c) => c.json(await getRoomsByCreator(c.env.DB, await ownerId(c)))) + // Auth-gated — no token is a 401, never account 1. + .get('/roomserver/rooms/createdby/me', ownedRooms) + .get('/rooms/ownedby/me', ownedRooms) + .get('/rooms/createdby/me', ownedRooms) + + // Rooms the caller has favorited (from the interaction table). Auth-gated. + // Paginated via skip/take (take defaults to 100). Returns a bare array, like the + // other room-source `*by/me` lists the client loads. + .get('/rooms/favoritedby/me', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take)) + }) + + // Rooms the caller has visited (interaction rows with a last-visited time). + // Auth-gated. Paginated via skip/take (take defaults to 100). Returns a bare array. + .get('/rooms/visitedby/me', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take)) + }) // The current player's interaction state with a room (cheered/favorited/last - // visited), read from the `interaction` table. + // visited), read from the `interaction` table. Auth-gated. .get('/rooms/:roomId{[0-9]+}/interactionby/me', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) const interaction = await getInteraction( c.env.DB, - await ownerId(c), + accountId, Number.parseInt(c.req.param('roomId'), 10) ) return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() }) }) - // Toggle the player's cheer/favorite on a room. Both are PUTs that flip the - // stored flag and return the updated interaction. + // Toggle the player's cheer/favorite on a room. Both are auth-gated PUTs that + // flip the stored flag and return the updated interaction. .put('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) const interaction = await toggleCheer( c.env.DB, - await ownerId(c), + accountId, Number.parseInt(c.req.param('roomId'), 10) ) return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() }) }) .put('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) const interaction = await toggleFavorite( c.env.DB, - await ownerId(c), + accountId, Number.parseInt(c.req.param('roomId'), 10) ) return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() }) }) + // Clone a room into a new one owned by the caller, using the `name` form field + // (also accepted as a query param). Auth is required — no valid token is a 401, + // with no stub-account fallback. Returns the `{ success, error, value }` envelope + // the client expects; business failures are 200 with success:false. + .post('/rooms/:roomId{[0-9]+}/clone', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) { + return c.json({ success: false, error: 'Unauthorized', value: null }, 401) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const raw = body.name ?? c.req.query('name') ?? '' + const name = typeof raw === 'string' ? raw.trim() : '' + + if (name === '') return cloneResult(c, null, 'You must enter a name for your room.') + if (await getRoomByName(c.env.DB, name)) { + return cloneResult(c, null, 'A room with that name already exists!') + } + const room = await cloneRoom( + c.env.DB, + Number.parseInt(c.req.param('roomId'), 10), + name, + accountId + ) + if (!room) return cloneResult(c, null, "You can't clone this room!") + return cloneResult(c, room) + }) + + // Rooms similar to the given room (sharing tags). Paginated via skip/take (take + // defaults to 100). Returns a bare array; empty when the room is unknown/untagged. + .get('/rooms/:roomId{[0-9]+}/similar', async (c) => { + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json( + await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take) + ) + }) + // Single room by id. 404 when the room isn't in D1. Ignores the // include/unityAsset* query params. .get('/rooms/:roomId{[0-9]+}', async (c) => { diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 6164efb..18daad3 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -99,9 +99,14 @@ describe('rooms endpoints', () => { expect(body.map((r) => r.Name)).toEqual(['RecCenter']) }) - it('GET /rooms/ownedby/me returns the caller created rooms (auth-scoped)', async () => { - // No token → stub account 1, which owns all the seeded rooms. - const mine = (await (await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`)).json()) as unknown[] + 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. + const mine = (await ( + await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('1') }) + ).json()) as unknown[] expect(mine.length).toBe(importRooms.length) // A different account owns none of them. const other = (await ( @@ -147,6 +152,255 @@ describe('rooms endpoints', () => { expect(aliased.TotalResults).toBeGreaterThan(0) }) + 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/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 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/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/:id/similar returns a bare array 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 Array<{ RoomId: number; Tags?: Array<{ Tag: string }> }> + expect(Array.isArray(body)).toBe(true) + expect(body.length).toBeGreaterThan(0) + // Never includes the target room itself. + expect(body.some((r) => r.RoomId === 2)).toBe(false) + // Every result shares the `rro` tag RecCenter (room 2) carries. + expect(body.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 unknown[] + expect(body.length).toBeLessThanOrEqual(3) + }) + + it('GET /rooms/:id/similar returns [] 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([]) + }) + + 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 }> } | 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 `base` template tag is dropped so clones aren't listed as base rooms. + expect((ok.value!.Tags ?? []).some((t) => t.Tag === 'base')).toBe(false) + + // 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 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('GET /photon_access_token (bare + /roomserver) returns permissions', async () => { for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { const res = await SELF.fetch(`${ORIGIN}${path}`)