mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
fix some custom room authoring issues
This commit is contained in:
+143
-34
@@ -29,6 +29,7 @@ import {
|
||||
setRoomImage,
|
||||
setRoomName,
|
||||
setRoomRole,
|
||||
updateRoomFields,
|
||||
toggleCheer,
|
||||
toggleFavorite,
|
||||
toggleRoomTag,
|
||||
@@ -139,6 +140,20 @@ function unauthorized(c: Context<App>) {
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* The room `Supports*` flags the `/restrictions` endpoint can toggle, keyed by the
|
||||
* lowercased form field the client posts. Only fields present in the body are changed.
|
||||
*/
|
||||
const RESTRICTION_FIELDS: Record<string, string> = {
|
||||
supportsscreens: 'SupportsScreens',
|
||||
supportswalkvr: 'SupportsWalkVR',
|
||||
supportsteleportvr: 'SupportsTeleportVR',
|
||||
supportsvrlow: 'SupportsVRLow',
|
||||
supportsquest2: 'SupportsQuest2',
|
||||
supportsmobile: 'SupportsMobile',
|
||||
supportsjuniors: 'SupportsJuniors',
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a RoomUpdate notification to a player after their room changes, mirroring
|
||||
* the reference server's `HubSendToPlayer(playerId, NotifFrame("RoomUpdate", room))`.
|
||||
@@ -568,11 +583,11 @@ const app = new Hono<App>()
|
||||
})
|
||||
|
||||
// Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to
|
||||
// the room creator or a co-owner — the same owner/co-owner check the other
|
||||
// room-admin actions use. Body is the `role` form field (an integer role tier).
|
||||
// Updates the target account's existing role entry or adds one, notifies the
|
||||
// affected member so their client refreshes permissions, and returns the
|
||||
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||
// the room creator or a co-owner (403 otherwise) — the same owner/co-owner check
|
||||
// the other room-admin actions use. Body is the `role` form field (an integer role
|
||||
// tier). Updates the target account's existing role entry or adds one, notifies the
|
||||
// affected member so their client refreshes permissions, and returns the updated
|
||||
// room in the lowercase `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
@@ -580,36 +595,134 @@ const app = new Hono<App>()
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (!canManageRoom(room, accountId)) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.PermissionDenied',
|
||||
Error: 'You are not the owner of this room!',
|
||||
})
|
||||
}
|
||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
|
||||
if (Number.isNaN(role)) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidRole',
|
||||
Error: 'You must provide a valid role!',
|
||||
})
|
||||
}
|
||||
if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!')
|
||||
|
||||
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
|
||||
// Notify the member whose role changed so their client refreshes the room
|
||||
// (and the permissions it grants them).
|
||||
await pushRoomUpdate(c, targetAccountId, updated)
|
||||
return roomResult(c, { Success: true })
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
|
||||
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
||||
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
|
||||
// the `warningMask` form field (an integer) and an optional `customWarning` string
|
||||
// (set when present — an empty value clears it). Returns the updated room in the
|
||||
// `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/warning', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const warningMask =
|
||||
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
|
||||
if (Number.isNaN(warningMask)) return roomEnvelope(c, null, 'You must provide a valid warning mask!')
|
||||
|
||||
const patch: Record<string, unknown> = { WarningMask: warningMask }
|
||||
// Only touch CustomWarning when the field is present (an empty string clears it).
|
||||
if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning
|
||||
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||
// Notify the owner so their client refreshes the room with the updated warning.
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
|
||||
// Toggle whether a room may be cloned (`CloningAllowed`). Auth-gated (401) and
|
||||
// owner/co-owner-only (403). Body is the `cloningAllowed` form field (`True`/`False`).
|
||||
// Returns the updated room in the `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/cloning', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
if (typeof body.cloningAllowed !== 'string') {
|
||||
return roomEnvelope(c, null, 'You must provide cloningAllowed.')
|
||||
}
|
||||
const cloningAllowed = body.cloningAllowed.toLowerCase() === 'true'
|
||||
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, { CloningAllowed: cloningAllowed })
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
|
||||
// Set a room's platform/movement support flags (its `Supports*` restrictions).
|
||||
// Auth-gated (401) and owner/co-owner-only (403). Body is a form of
|
||||
// `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present
|
||||
// are changed. Returns the updated room in the `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/restrictions', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const patch: Record<string, boolean> = {}
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const field = RESTRICTION_FIELDS[key.toLowerCase()]
|
||||
if (field !== undefined && typeof value === 'string') {
|
||||
patch[field] = value.toLowerCase() === 'true'
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
|
||||
// Add a load screen to a room (`LoadScreens[]` — the images shown while the room
|
||||
// loads). Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName`
|
||||
// form field plus optional `title`/`subtitle`. Appends one
|
||||
// `{ ImageName, Title, Subtitle }` to the existing list and returns the updated
|
||||
// room in the `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/loadscreen', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
||||
if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!')
|
||||
const title = typeof body.title === 'string' ? body.title : ''
|
||||
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
|
||||
|
||||
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
|
||||
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
|
||||
// A subroom's data descriptor (the SubRoom object from the room's SubRooms
|
||||
@@ -643,13 +756,9 @@ const app = new Hono<App>()
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (!canManageRoom(room, accountId)) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.PermissionDenied',
|
||||
Error: 'You are not the owner of this room!',
|
||||
})
|
||||
}
|
||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
RoomData?: { Filename?: string }
|
||||
|
||||
@@ -413,6 +413,7 @@ describe('rooms endpoints', () => {
|
||||
Name: string
|
||||
CreatorAccountId: number
|
||||
Tags?: Array<{ Tag: string }>
|
||||
IsRRO: boolean
|
||||
Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }>
|
||||
} | null
|
||||
}
|
||||
@@ -428,6 +429,8 @@ describe('rooms endpoints', () => {
|
||||
// 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)
|
||||
// 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([
|
||||
@@ -507,7 +510,8 @@ describe('rooms endpoints', () => {
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
|
||||
// Room-mutation envelope helper.
|
||||
// Room-mutation envelope helper (PascalCase `{ Success, Value, ErrorId, Error }` —
|
||||
// used by name/image/description).
|
||||
type RoomResult = {
|
||||
Success: boolean
|
||||
Value: unknown
|
||||
@@ -516,6 +520,11 @@ describe('rooms endpoints', () => {
|
||||
}
|
||||
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
|
||||
|
||||
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)
|
||||
@@ -584,26 +593,27 @@ describe('rooms endpoints', () => {
|
||||
// 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) → Success:false.
|
||||
expect(await bodyOf(await putForm('/rooms/2/roles/5', { role: '20' }, '999'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.PermissionDenied',
|
||||
// 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!',
|
||||
})
|
||||
// Unknown room → Rooms.DoesntExist envelope.
|
||||
expect(await bodyOf(await putForm('/rooms/99999/roles/5', { role: '20' }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
})
|
||||
// Non-numeric role → Success:false.
|
||||
expect(await bodyOf(await putForm('/rooms/2/roles/5', { role: 'nope' }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidRole',
|
||||
// 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.
|
||||
// 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)
|
||||
expect(await bodyOf(ok)).toMatchObject({ Success: true })
|
||||
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
|
||||
@@ -617,6 +627,173 @@ describe('rooms endpoints', () => {
|
||||
expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 }))
|
||||
})
|
||||
|
||||
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 appends a 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,
|
||||
})
|
||||
|
||||
const before = (await screensOf()).length
|
||||
|
||||
// Owner adds one (imageName + title + subtitle) — appended, and the success
|
||||
// envelope carries the updated room.
|
||||
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 as unknown[]).toContainEqual({
|
||||
ImageName: 'sharecamera/2026-07-15/abc.jpg',
|
||||
Title: 'asdf',
|
||||
Subtitle: 'sdf',
|
||||
})
|
||||
expect(await screensOf()).toHaveLength(before + 1)
|
||||
|
||||
// A second call appends rather than replacing; title/subtitle default to empty.
|
||||
const co = await envOf(await putForm('/rooms/2/loadscreen', { imageName: 'second.jpg' }, '2'))
|
||||
expect(co).toMatchObject({ success: true })
|
||||
expect(await screensOf()).toHaveLength(before + 2)
|
||||
expect(await screensOf()).toContainEqual({ ImageName: 'second.jpg', Title: '', Subtitle: '' })
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/subrooms/:sid/data returns the subroom descriptor (404 when unknown)', async () => {
|
||||
// Room 2 has SubRoomId 2 in the seed.
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)
|
||||
@@ -661,12 +838,9 @@ describe('rooms endpoints', () => {
|
||||
body: JSON.stringify(save),
|
||||
})
|
||||
|
||||
// A valid token but no role on the room → 403.
|
||||
expect((await authed(2, 2, '999')).status).toBe(403)
|
||||
// The response uses the PascalCase `{ Success, Value, ErrorId, Error }` envelope.
|
||||
// Wrong owner (no role) → PermissionDenied.
|
||||
expect(await bodyOf(await authed(2, 2, '999'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.PermissionDenied',
|
||||
})
|
||||
// Unknown room → DoesntExist.
|
||||
expect(await bodyOf(await authed(99999, 2, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
|
||||
Reference in New Issue
Block a user