mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
implement stubs for /similar, visited, favroited, ownedby...implement clone
This commit is contained in:
+197
-6
@@ -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<string, unknown>
|
||||
|
||||
/**
|
||||
* 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<Room | null> {
|
||||
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<Record<string, unknown>>).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<Room[]> {
|
||||
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<RoomRow>()
|
||||
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<Room[]> {
|
||||
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<RoomRow>()
|
||||
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<string, string[]> = {
|
||||
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<string, unknown> | 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<string>): boolean {
|
||||
const roomTags = room.Tags
|
||||
if (!Array.isArray(roomTags)) return false
|
||||
return roomTags.some((t) => {
|
||||
const value = (t as Record<string, unknown> | 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<string, unknown> | 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<RoomRow>()
|
||||
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<Room[]> {
|
||||
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<RoomRow>()
|
||||
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<Room[]> {
|
||||
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
|
||||
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)
|
||||
}
|
||||
|
||||
+123
-20
@@ -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<App>): Promise<number> {
|
||||
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
|
||||
async function authedAccountId(c: Context<App>): Promise<number | null> {
|
||||
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<App>) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
|
||||
/** Client envelope for room clone results: `{ success, error, value }`. */
|
||||
function cloneResult(c: Context<App>, 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<App>) {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
return c.json(await getRoomsByCreator(c.env.DB, accountId))
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
@@ -127,6 +147,25 @@ const app = new Hono<App>()
|
||||
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<App>()
|
||||
})
|
||||
|
||||
// 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<string, unknown>
|
||||
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) => {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user