mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[rooms] fix my rooms
This commit is contained in:
@@ -301,6 +301,13 @@ export const RoomDto = z.object({
|
||||
.int()
|
||||
.nullable()
|
||||
.describe('The room’s published snapshot. Nothing takes snapshots here, so always null'),
|
||||
FriendlyName: z
|
||||
.string()
|
||||
.describe('Display name. Nothing sets one apart from `Name` here, so it mirrors `Name`'),
|
||||
CCU: z
|
||||
.int()
|
||||
.nullable()
|
||||
.describe('Concurrent users. Nothing counts live population here, so always null'),
|
||||
RankingContext: z.unknown().nullable(),
|
||||
IsDorm: z.boolean().describe('Auto-provisioned personal room; excluded from every feed'),
|
||||
IsPlacePlay: z.boolean(),
|
||||
|
||||
+26
-15
@@ -733,6 +733,10 @@ const app = new Hono<App>()
|
||||
// Room search: `query` is space/`+`-separated terms — `#tag` matches room tags,
|
||||
// plain terms match the name. Public, non-dorm rooms only. Paginated via
|
||||
// skip/take. Returns `{ Results, TotalResults }`.
|
||||
//
|
||||
// `#community` is the one tag term that isn't a tag lookup: it is the browse chip's
|
||||
// pseudo-tag reaching search, and means rooms a PLAYER made rather than the seeded
|
||||
// first-party ones — the same filter `/rooms/hot?tag=community` applies.
|
||||
.get(
|
||||
'/rooms/search',
|
||||
describeRoute({
|
||||
@@ -741,9 +745,14 @@ const app = new Hono<App>()
|
||||
description: [
|
||||
'Full room search. `query` is space- or `+`-separated terms: a `#tag` term matches the',
|
||||
'room’s tags, a plain term matches its name. Public, non-dorm rooms only.',
|
||||
'`#community` is a pseudo-tag no room carries — it narrows to rooms a player made',
|
||||
'(anything the system Coach account didn’t create), like `/rooms/hot?tag=community`.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
stringQuery('query', 'Search terms — `#tag` matches tags, plain terms match the name'),
|
||||
stringQuery(
|
||||
'query',
|
||||
'Search terms — `#tag` matches tags, plain terms match the name, `#community` matches player-made rooms'
|
||||
),
|
||||
...pageParams(30),
|
||||
],
|
||||
responses: { 200: json(PagedRooms, 'The matching rooms') },
|
||||
@@ -1093,13 +1102,15 @@ const app = new Hono<App>()
|
||||
ownedRooms
|
||||
)
|
||||
|
||||
// Rooms the caller CONTRIBUTES to — someone else's rooms that name them in `Roles`
|
||||
// (Host, Moderator or CoOwner). Auth-scoped: `me` resolves from the bearer token, and
|
||||
// there is no query string or body to read.
|
||||
// Rooms the caller works on — the ones they CREATED plus anyone else's that name them in
|
||||
// `Roles` (Host, Moderator or CoOwner). Auth-scoped: `me` resolves from the bearer token,
|
||||
// and there is no query string or body to read.
|
||||
//
|
||||
// Rooms the caller created are excluded: a room's `Roles` carries its creator too, so
|
||||
// without that this would repeat `createdby/me` wholesale, and the client shows the two
|
||||
// as separate lists. Like the other `*by/me` lists it answers a bare array of the
|
||||
// Created rooms used to be excluded, on the grounds that a room's `Roles` names its
|
||||
// creator too and the client shows "owned" and "contributed" separately. That left the
|
||||
// list empty for every account that had only built its own rooms — most of them — so it
|
||||
// now overlaps `createdby/me` rather than coming back empty. The dorm stays out, as it
|
||||
// does on `ownedby/me`. Like the other `*by/me` lists it answers a bare array of the
|
||||
// canonical room DTO — no envelope, no paging wrapper — and doesn't filter on
|
||||
// accessibility, since a contributor is working on the room whether or not it's
|
||||
// published.
|
||||
@@ -1107,18 +1118,18 @@ const app = new Hono<App>()
|
||||
'/rooms/contributedby/me',
|
||||
describeRoute({
|
||||
tags: ['My rooms'],
|
||||
summary: 'Rooms the caller contributes to',
|
||||
summary: 'Rooms the caller owns or contributes to',
|
||||
description: [
|
||||
'The rooms that name the caller in their `Roles` — Host, Moderator or CoOwner — as a',
|
||||
'bare array of rooms. Rooms the caller CREATED are excluded: those are',
|
||||
'`ownedby/me`/`createdby/me`, and a room’s roles list its creator too, so including',
|
||||
'them would just repeat that list. Every role tier counts, not only the owner-level',
|
||||
'ones, and accessibility is not filtered: a contributor works on the room whether or',
|
||||
'not it is published.',
|
||||
'Every room the caller works on, as a bare array: the ones they CREATED plus the ones',
|
||||
'that name them in their `Roles` — Host, Moderator or CoOwner. Overlaps',
|
||||
'`createdby/me` deliberately, so a client rendering one list sees everything; the',
|
||||
'dorm is excluded as it is on `ownedby/me`. Every role tier counts, not only the',
|
||||
'owner-level ones, and accessibility is not filtered: a contributor works on the room',
|
||||
'whether or not it is published.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(RoomDto.array(), 'The rooms the caller contributes to (empty when none)'),
|
||||
200: json(RoomDto.array(), 'The rooms the caller owns or contributes to (empty when none)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -153,15 +153,18 @@ describe('rooms endpoints', () => {
|
||||
expect(body.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
|
||||
})
|
||||
|
||||
// Neither is stored — the seed blobs predate both keys — so they are defaulted on read.
|
||||
// The client's room DTO always carries them, and an ABSENT key is not the same as a
|
||||
// zero/null one to its parser.
|
||||
it('GET /rooms/:id carries BoostCount and CurrentSnapshotId', async () => {
|
||||
// None of these are stored — the seed blobs predate the keys — so they are defaulted on
|
||||
// read. The client's room DTO always carries them, and an ABSENT key is not the same as a
|
||||
// zero/null one to its parser. `FriendlyName` is the one that can't be null: the client
|
||||
// labels the room from it.
|
||||
it('GET /rooms/:id carries BoostCount, CurrentSnapshotId, FriendlyName and CCU', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/1`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body).toHaveProperty('BoostCount', 0)
|
||||
expect(body).toHaveProperty('CurrentSnapshotId', null)
|
||||
expect(body).toHaveProperty('FriendlyName', body.Name)
|
||||
expect(body).toHaveProperty('CCU', null)
|
||||
})
|
||||
|
||||
// Pinned whole: these are the numbers the client's publish UI counts against, and
|
||||
@@ -418,7 +421,7 @@ describe('rooms endpoints', () => {
|
||||
expect(publicList.some((r) => r.Name === 'MyUnpublishedRoom')).toBe(false)
|
||||
})
|
||||
|
||||
it('GET /rooms/contributedby/me lists rooms the caller has a role in, not their own', async () => {
|
||||
it('GET /rooms/contributedby/me lists rooms the caller owns or has a role in', async () => {
|
||||
const seed = (data: Record<string, unknown>) =>
|
||||
env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)).run()
|
||||
|
||||
@@ -445,7 +448,8 @@ describe('rooms endpoints', () => {
|
||||
SubRooms: [],
|
||||
Roles: [{ AccountId: 820, Role: 10 }],
|
||||
})
|
||||
// ...one they created themselves, whose Roles name them as Creator...
|
||||
// ...one they created themselves, whose Roles name them as Creator (matched by BOTH
|
||||
// halves of the query, so it must still appear exactly once)...
|
||||
await seed({
|
||||
RoomId: 30403,
|
||||
Name: 'ContribOwn',
|
||||
@@ -454,6 +458,25 @@ describe('rooms endpoints', () => {
|
||||
SubRooms: [],
|
||||
Roles: [{ AccountId: 820, Role: 255 }],
|
||||
})
|
||||
// ...one they created that names nobody in Roles at all — the older rooms have no
|
||||
// Roles key, and those reach the list on the creator half alone...
|
||||
await seed({
|
||||
RoomId: 30406,
|
||||
Name: 'ContribOwnNoRoles',
|
||||
CreatorAccountId: 820,
|
||||
Accessibility: 1,
|
||||
SubRooms: [],
|
||||
})
|
||||
// ...and their dorm, which stays out: auto-provisioned, not a room they made.
|
||||
await seed({
|
||||
RoomId: 30407,
|
||||
Name: "@player820's Dorm",
|
||||
CreatorAccountId: 820,
|
||||
IsDorm: true,
|
||||
Accessibility: 2,
|
||||
SubRooms: [],
|
||||
Roles: [{ AccountId: 820, Role: 255 }],
|
||||
})
|
||||
// ...one they have nothing to do with, and one with no Roles key at all (the older
|
||||
// seeded rooms have none — json_each must drop them, not error).
|
||||
await seed({
|
||||
@@ -473,9 +496,10 @@ describe('rooms endpoints', () => {
|
||||
const rooms = (await res.json()) as Array<{ RoomId: number; Name: string }>
|
||||
// A bare array of the canonical room DTO — no envelope, no paging wrapper.
|
||||
expect(Array.isArray(rooms)).toBe(true)
|
||||
expect(rooms.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([30401, 30402])
|
||||
// The caller's OWN room is excluded, or this would just repeat createdby/me.
|
||||
expect(rooms.some((r) => r.RoomId === 30403)).toBe(false)
|
||||
expect(rooms.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([30401, 30402, 30403, 30406])
|
||||
// Their own rooms are IN — the list overlaps createdby/me rather than coming back
|
||||
// empty for someone who only ever built their own — but the dorm is not.
|
||||
expect(rooms.some((r) => r.RoomId === 30407)).toBe(false)
|
||||
expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) })
|
||||
|
||||
// A player who contributes to nothing gets an empty array, not a 404.
|
||||
@@ -489,7 +513,7 @@ describe('rooms endpoints', () => {
|
||||
|
||||
// The DB is shared across this file, and these are the only player-made public rooms
|
||||
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
|
||||
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30405').run()
|
||||
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
|
||||
})
|
||||
|
||||
it('GET /rooms/:roomId/experience serves the fixed XP settings, no auth', async () => {
|
||||
@@ -612,6 +636,57 @@ describe('rooms endpoints', () => {
|
||||
expect(aliased.TotalResults).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('GET /rooms/search?query=#community serves rooms the Coach account did not create', async () => {
|
||||
// The browse chip's word reaches search as a tag term, but no room CARRIES a
|
||||
// `community` tag — it is the same pseudo-tag `/rooms/hot?tag=community` applies, so it
|
||||
// filters on who MADE the room. Every seeded room belongs to Coach (account 1), so it
|
||||
// finds nothing until another account makes something.
|
||||
type Page = { Results: Array<{ Name: string }>; TotalResults: number }
|
||||
const search = async (query: string): Promise<Page> =>
|
||||
(await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/search?query=${encodeURIComponent(query)}&take=100`)
|
||||
).json()) as Page
|
||||
|
||||
expect(await search('#community')).toEqual({ Results: [], TotalResults: 0 })
|
||||
|
||||
const seeded: number[] = []
|
||||
const seed = async (room: Record<string, unknown>) => {
|
||||
seeded.push(Number(room.RoomId))
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
Accessibility: 1,
|
||||
IsDorm: false,
|
||||
CreatorAccountId: 2,
|
||||
...room,
|
||||
})
|
||||
}
|
||||
|
||||
await seed({ RoomId: 9201, Name: 'CommunityHorrorHouse' })
|
||||
await seed({ RoomId: 9202, Name: 'CommunityArcade' })
|
||||
// Coach's own rooms stay out, and so do non-public ones as everywhere else in search.
|
||||
await seed({ RoomId: 9203, Name: 'CommunityCoachRoom', CreatorAccountId: 1 })
|
||||
await seed({ RoomId: 9204, Name: 'CommunityUnlisted', Accessibility: 2 })
|
||||
|
||||
const found = await search('#community')
|
||||
expect(found.Results.map((r) => r.Name)).toEqual(['CommunityHorrorHouse', 'CommunityArcade'])
|
||||
expect(found.TotalResults).toBe(2)
|
||||
|
||||
// The client sends the chip with a trailing space (`?query=%23community+`), which the
|
||||
// term split has to swallow rather than search for an empty second term.
|
||||
expect(await search('#community ')).toEqual(found)
|
||||
|
||||
// It NARROWS the rest of the query rather than replacing it: a name term still applies,
|
||||
// and so does a real tag, which is still the SQL lookup it always was.
|
||||
expect((await search('#community arcade')).Results.map((r) => r.Name)).toEqual([
|
||||
'CommunityArcade',
|
||||
])
|
||||
expect(await search('#community #rro')).toEqual({ Results: [], TotalResults: 0 })
|
||||
|
||||
// Leave the shared dataset as it was for the tests that follow.
|
||||
const ids = seeded.join(',')
|
||||
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
|
||||
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
|
||||
})
|
||||
|
||||
it('GET /rooms/favoritedby/me returns a bare array of the caller favorited rooms (auth-scoped)', async () => {
|
||||
const headers = await bearer('777')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user