mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
trying to get rooms up
This commit is contained in:
@@ -2,7 +2,8 @@ import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
// add additional Bindings here
|
||||
// D1 database holding rooms (JSON blob + generated columns). See rooms-db.ts.
|
||||
DB: D1Database
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`.
|
||||
*
|
||||
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`).
|
||||
* Swap both for a shared secret binding before this is used for anything real.
|
||||
*/
|
||||
const DEV_SECRET = 'dev-insecure-signing-key-change-me'
|
||||
|
||||
function base64urlToBytes(input: string): Uint8Array {
|
||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an HS256 token and return its `sub` (account id) claim, or `null`
|
||||
* when the token is malformed, has a bad signature, or is expired.
|
||||
*/
|
||||
export async function validateAndGetAccountId(
|
||||
token: string,
|
||||
secret: string = DEV_SECRET
|
||||
): Promise<string | null> {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const [header, payload, signature] = parts
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
)
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
base64urlToBytes(signature),
|
||||
new TextEncoder().encode(`${header}.${payload}`)
|
||||
)
|
||||
if (!valid) return null
|
||||
|
||||
let claims: { sub?: string; exp?: number }
|
||||
try {
|
||||
claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload)))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return claims.sub ?? null
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Room storage on D1. Each room is a single JSON blob in the `data` column;
|
||||
* queryable fields (RoomId, Name, CreatorAccountId, IsDorm) are SQLite
|
||||
* generated (virtual) columns extracted from that JSON and indexed. This keeps
|
||||
* the room shape flexible while still allowing fast lookups by id/name/creator.
|
||||
*
|
||||
* `SCHEMA_DDL` mirrors `migrations/0001_init.sql`; the room data is seeded from
|
||||
* `static/ImportRooms.json` by `migrations/0002_import_rooms.sql`. Tests apply
|
||||
* `SCHEMA_DDL` then seed the imported rooms directly.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_init.sql, sans the seed INSERT). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS rooms (
|
||||
data TEXT NOT NULL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
name TEXT GENERATED ALWAYS AS (json_extract(data, '$.Name')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
|
||||
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON rooms (room_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON rooms (name_lower)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_rooms_creator ON rooms (creator_account_id)`,
|
||||
]
|
||||
|
||||
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
||||
export type Room = Record<string, unknown>
|
||||
|
||||
interface RoomRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.data) as Room) : null)
|
||||
const parseAll = (rows: RoomRow[]): Room[] => rows.map((r) => JSON.parse(r.data) as Room)
|
||||
|
||||
/** Look up a single room by its RoomId. */
|
||||
export async function getRoomById(db: D1Database, roomId: number): Promise<Room | null> {
|
||||
return parseOne(
|
||||
await db.prepare('SELECT data FROM rooms WHERE room_id = ?1').bind(roomId).first<RoomRow>()
|
||||
)
|
||||
}
|
||||
|
||||
/** Look up a single room by name (case-insensitive exact match). */
|
||||
export async function getRoomByName(db: D1Database, name: string): Promise<Room | null> {
|
||||
return parseOne(
|
||||
await db
|
||||
.prepare('SELECT data FROM rooms WHERE name_lower = ?1')
|
||||
.bind(name.toLowerCase())
|
||||
.first<RoomRow>()
|
||||
)
|
||||
}
|
||||
|
||||
/** Look up multiple rooms by RoomId. */
|
||||
export async function getRoomsByIds(db: D1Database, ids: number[]): Promise<Room[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM rooms WHERE room_id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.all<RoomRow>()
|
||||
return parseAll(results)
|
||||
}
|
||||
|
||||
/** All rooms created by an account (e.g. their dorm). */
|
||||
export async function getRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM rooms WHERE creator_account_id = ?1')
|
||||
.bind(accountId)
|
||||
.all<RoomRow>()
|
||||
return parseAll(results)
|
||||
}
|
||||
+62
-99
@@ -3,26 +3,37 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds } from './rooms-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* Ported from the C# `RoomsController`. The C# backs these with EF Core
|
||||
* (`AppDbContext`); there's no DB binding yet, so room lookups synthesize the
|
||||
* same shape `BuildRoomResponse` produces. Responses are PascalCase — the C#
|
||||
* sets `PropertyNamingPolicy = null` and the anonymous response object uses
|
||||
* PascalCase member names (see JSON/ownedrooms.json).
|
||||
* Room server. Rooms are stored in D1 as JSON blobs with generated columns for
|
||||
* querying (see rooms-db.ts); the dorm (RoomId 1) is seeded by the migration.
|
||||
* Responses are the stored JSON verbatim (PascalCase, client-facing shape).
|
||||
*
|
||||
* The class `[Route("rooms")]` prefix maps to this worker's subdomain, so the
|
||||
* method routes are served bare (e.g. `/rooms/{id}`, not `/rooms/rooms/{id}`).
|
||||
* The C# `[Route("rooms")]` prefix maps to this worker's subdomain, so method
|
||||
* routes are served bare. The 2023 client also hits several of these without the
|
||||
* `/roomserver` prefix, so both forms are registered.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the full room payload the C# `BuildRoomResponse` returns. With no DB,
|
||||
* room 1 is the dorm (matching JSON/ownedrooms.json) and any other id gets a
|
||||
* generic published room so the client can still resolve and load it.
|
||||
*/
|
||||
/** Unity scene id for the dorm (also the matchmake/heartbeat instance location). */
|
||||
const DORM_SCENE_ID = '76d98498-60a1-430c-ab76-b54a29b7a163'
|
||||
/** Parse the first valid integer id from a comma-separated `id` query param. */
|
||||
function firstId(idParam: string): number | undefined {
|
||||
return idParam
|
||||
.split(',')
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.find((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
/** Parse all valid integer ids from a comma-separated `id` query param. */
|
||||
function allIds(idParam: string): number[] {
|
||||
return idParam
|
||||
.split(',')
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
/** Room permissions + (empty) Photon token the client needs to spawn into a room. */
|
||||
function photonAccessToken() {
|
||||
@@ -52,56 +63,16 @@ function photonAccessToken() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildRoomResponse(roomId: number) {
|
||||
const isDorm = roomId === 1
|
||||
return {
|
||||
RoomId: roomId,
|
||||
Name: isDorm ? 'DormRoom' : `Room${roomId}`,
|
||||
Description: isDorm ? 'Your private room' : '',
|
||||
CreatorAccountId: 1,
|
||||
ImageName: 'DefaultRoomImage.jpg',
|
||||
State: 0,
|
||||
Accessibility: 0,
|
||||
SupportsLevelVoting: false,
|
||||
IsRRO: false,
|
||||
IsDorm: isDorm,
|
||||
CloningAllowed: false,
|
||||
SupportsVRLow: true,
|
||||
SupportsQuest2: true,
|
||||
SupportsMobile: true,
|
||||
SupportsScreens: true,
|
||||
SupportsWalkVR: true,
|
||||
SupportsTeleportVR: true,
|
||||
SupportsJuniors: true,
|
||||
MinLevel: 0,
|
||||
WarningMask: 0,
|
||||
CustomWarning: null,
|
||||
DisableMicAutoMute: false,
|
||||
DisableRoomComments: false,
|
||||
EncryptVoiceChat: false,
|
||||
CreatedAt: '2026-01-18T02:31:37.6171131',
|
||||
Stats: { CheerCount: 0, FavoriteCount: 0, VisitorCount: 1, VisitCount: 1 },
|
||||
// The client needs a SubRoom (UnitySceneId + DataBlob) to load the scene.
|
||||
// The dorm points at the dorm scene; an empty DataBlob loads the default
|
||||
// build. Generic rooms have no known scene yet.
|
||||
SubRooms: [
|
||||
{
|
||||
SubRoomId: 1,
|
||||
Name: '',
|
||||
DataBlob: '',
|
||||
IsSandbox: false,
|
||||
MaxPlayers: 4,
|
||||
Accessibility: 0,
|
||||
UnitySceneId: isDorm ? DORM_SCENE_ID : '',
|
||||
DataSavedAt: '2026-01-18T02:31:37.6171131',
|
||||
},
|
||||
],
|
||||
Roles: [],
|
||||
LoadScreens: [],
|
||||
PromoImages: [],
|
||||
PromoExternalContent: [],
|
||||
Tags: [],
|
||||
/** 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> {
|
||||
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
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
@@ -120,63 +91,55 @@ const app = new Hono<App>()
|
||||
|
||||
.get('/', (c) => c.json({ service: 'rooms', status: 'ok' }))
|
||||
|
||||
// Room lookup by `id` (comma-separated; first match wins) or `name`. The C#
|
||||
// 400s when neither is supplied and returns `{}` when nothing matches.
|
||||
.get('/rooms', (c) => {
|
||||
// Room lookup by `id` (first match wins) or `name`. The C# 400s when neither
|
||||
// is supplied and returns `{}` when nothing matches.
|
||||
.get('/rooms', async (c) => {
|
||||
const idParam = c.req.query('id')
|
||||
const nameParam = c.req.query('name')
|
||||
if (!idParam && !nameParam) {
|
||||
return c.json("Either 'id' or 'name' query parameter is required", 400)
|
||||
}
|
||||
if (idParam) {
|
||||
const firstId = idParam
|
||||
.split(',')
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.find((n) => !Number.isNaN(n))
|
||||
if (firstId === undefined) return c.json({})
|
||||
return c.json(buildRoomResponse(firstId))
|
||||
const id = firstId(idParam)
|
||||
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
|
||||
return c.json(room ?? {})
|
||||
}
|
||||
// Looked up by name — no DB to resolve it, so synthesize a room 1 (dorm).
|
||||
// TODO: resolve the named room once a DB binding exists.
|
||||
return c.json(buildRoomResponse(1))
|
||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||
return c.json(room ?? {})
|
||||
})
|
||||
|
||||
// Bulk room lookup by `id` (synthesized per id) or `name`. The client calls
|
||||
// this bare on the rooms host. We have no named-room data, so a name lookup
|
||||
// returns [] — the client treats that as NoSuchRoom (a non-fatal warning),
|
||||
// which is the honest answer since we can't actually host that room.
|
||||
.get('/rooms/bulk', (c) => {
|
||||
// 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.
|
||||
.get('/rooms/bulk', async (c) => {
|
||||
const idParam = c.req.query('id')
|
||||
const nameParam = c.req.query('name')
|
||||
if (!idParam && !nameParam) {
|
||||
return c.json("Either 'id' or 'name' query parameter is required", 400)
|
||||
}
|
||||
if (idParam) {
|
||||
const ids = idParam
|
||||
.split(',')
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
return c.json(ids.map(buildRoomResponse))
|
||||
return c.json(await getRoomsByIds(c.env.DB, allIds(idParam)))
|
||||
}
|
||||
return c.json([])
|
||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||
return c.json(room ? [room] : [])
|
||||
})
|
||||
|
||||
// Single room by id. The C# 404s when the row is missing; with no DB we
|
||||
// synthesize the room so the client can load it (ignores the include/
|
||||
// unityAsset* query params, same as the C#).
|
||||
.get('/rooms/:roomId{[0-9]+}', (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
return c.json(buildRoomResponse(roomId))
|
||||
})
|
||||
// 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))))
|
||||
|
||||
// Rooms created by the caller. The C# serves JSON/ownedrooms.json (the dorm).
|
||||
.get('/roomserver/rooms/createdby/me', (c) => c.json([buildRoomResponse(1)]))
|
||||
// Single room by id. 404 when the room isn't in D1 (matches the C#). Ignores
|
||||
// the include/unityAsset* query params, same as the C#.
|
||||
.get('/rooms/:roomId{[0-9]+}', async (c) => {
|
||||
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
||||
return room ? c.json(room) : c.notFound()
|
||||
})
|
||||
|
||||
// Photon access token + room permissions the client needs to spawn into a
|
||||
// room. Without it the player is stuck on a black screen. PhotonAccessToken is
|
||||
// empty (the client uses its baked-in Photon credentials); roomInstanceId is
|
||||
// our constant 1. The client calls it on the rooms host both bare and under
|
||||
// `/roomserver`, so both are registered.
|
||||
// room. The client calls it on the rooms host both bare and under `/roomserver`.
|
||||
.get('/photon_access_token', (c) => c.json(photonAccessToken()))
|
||||
.get('/roomserver/photon_access_token', (c) => c.json(photonAccessToken()))
|
||||
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
import { SELF } from 'cloudflare:test'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../rooms.app'
|
||||
|
||||
import importRooms from '../../../static/ImportRooms.json'
|
||||
import { SCHEMA_DDL } from '../../rooms-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
|
||||
const ORIGIN = 'https://rooms.rec.djdevin.net'
|
||||
|
||||
// Mint a token the way the `auth` worker does, using the same dev secret.
|
||||
const DEV_SECRET = 'dev-insecure-signing-key-change-me'
|
||||
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(/=+$/, '')
|
||||
}
|
||||
async function bearer(sub: 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 })
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(DEV_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)}` }
|
||||
}
|
||||
|
||||
// Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations).
|
||||
beforeAll(async () => {
|
||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
|
||||
await env.DB.batch(importRooms.map((r) => insert.bind(JSON.stringify(r))))
|
||||
})
|
||||
|
||||
describe('rooms endpoints', () => {
|
||||
it('GET / reports service status', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/`)
|
||||
@@ -12,34 +52,33 @@ describe('rooms endpoints', () => {
|
||||
expect(await res.json()).toEqual({ service: 'rooms', status: 'ok' })
|
||||
})
|
||||
|
||||
it('GET /rooms/1 returns the dorm room (ignoring include/unityAsset params)', async () => {
|
||||
const res = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/1?include=1325&unityAssetTarget=0&unityAssetVersion=1`
|
||||
)
|
||||
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
|
||||
Stats: object
|
||||
SubRooms: Array<{ UnitySceneId: string }>
|
||||
}
|
||||
expect(body).toMatchObject({ RoomId: 1, Name: 'DormRoom', IsDorm: true })
|
||||
expect(body).toHaveProperty('Stats')
|
||||
const subRooms = (body as unknown as { SubRooms: Array<{ UnitySceneId: string }> }).SubRooms
|
||||
expect(subRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
|
||||
expect(body.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
|
||||
})
|
||||
|
||||
it('GET /rooms/:id synthesizes a generic room for other ids', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/42`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { RoomId: number; Name: string; IsDorm: boolean }
|
||||
expect(body).toMatchObject({ RoomId: 42, Name: 'Room42', IsDorm: false })
|
||||
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?id=1 returns the matching room', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms?id=1`)
|
||||
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({ RoomId: 1, Name: 'DormRoom' })
|
||||
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 () => {
|
||||
@@ -47,36 +86,36 @@ describe('rooms endpoints', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('GET /roomserver/photon_access_token returns permissions + instance id', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/roomserver/photon_access_token`)
|
||||
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 { Permissions: unknown[]; RoomInstanceId: number }
|
||||
expect(body.Permissions.length).toBeGreaterThan(0)
|
||||
expect(body.RoomInstanceId).toBe(1)
|
||||
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?id= returns an array; ?name= returns []', async () => {
|
||||
const byId = await SELF.fetch(`${ORIGIN}/rooms/bulk?id=1,2`)
|
||||
expect(byId.status).toBe(200)
|
||||
expect(((await byId.json()) as unknown[]).length).toBe(2)
|
||||
const byName = await SELF.fetch(`${ORIGIN}/rooms/bulk?name=RecCenter`)
|
||||
expect(byName.status).toBe(200)
|
||||
expect(await byName.json()).toEqual([])
|
||||
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('GET /photon_access_token (bare) also returns permissions', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { Permissions: unknown[]; RoomInstanceId: number }
|
||||
expect(body.Permissions.length).toBeGreaterThan(0)
|
||||
expect(body.RoomInstanceId).toBe(1)
|
||||
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[]
|
||||
expect(mine.length).toBe(importRooms.length)
|
||||
// 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 /roomserver/rooms/createdby/me returns the owned rooms array', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/roomserver/rooms/createdby/me`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Array<{ RoomId: number }>
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body[0]).toMatchObject({ RoomId: 1, Name: 'DormRoom' })
|
||||
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}`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { Permissions: unknown[]; RoomInstanceId: number }
|
||||
expect(body.Permissions.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user