mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
stubs
This commit is contained in:
@@ -1752,6 +1752,44 @@ const app = new Hono<App>({ strict: false })
|
||||
c.json([])
|
||||
)
|
||||
|
||||
// The room-economy surface the client asks for on entering a room: the room's own
|
||||
// inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is
|
||||
// stored yet, so every one is an empty list — the client reads that as "this room
|
||||
// sells nothing" and renders no shop, where a 404 stalls the room load instead.
|
||||
//
|
||||
// The `/player` and `purchaseCounts` variants are caller-scoped but deliberately
|
||||
// unauthed, matching the `roomConsumable/.../me` stub above: an empty list is the
|
||||
// same answer for every caller, so there's nothing to protect until something
|
||||
// writes here. Gate them when they start returning real data.
|
||||
.get(
|
||||
'/econ/roomInventory/room/:roomId',
|
||||
listRoute('A room’s inventory', 'Empty stub so the client doesn’t 404'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/econ/roomInventory/room/:roomId/player',
|
||||
listRoute('The caller’s inventory in a room', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/econ/roomInventoryItemTags/room/:roomId',
|
||||
listRoute('A room’s inventory item tags', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get('/econ/roomOffer/room/:roomId', listRoute('A room’s offers', 'Empty stub'), (c) =>
|
||||
c.json([])
|
||||
)
|
||||
.get(
|
||||
'/econ/roomOffer/room/:roomId/purchaseCounts',
|
||||
listRoute('Per-offer purchase counts for a room', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/econ/roomGiftDropShops/room/:roomId',
|
||||
listRoute('A room’s gift-drop shops', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
|
||||
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
|
||||
// unlocked-consumable DTO. A player who has bought none gets an empty list.
|
||||
|
||||
@@ -635,6 +635,24 @@ describe('econ endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
// The room-economy stubs. One table-driven test: they're the same empty-list answer,
|
||||
// and what's worth pinning is that every path the client asks for on room entry is
|
||||
// registered — an unregistered one 404s and stalls the room load.
|
||||
test('the room-economy endpoints all return []', async () => {
|
||||
for (const path of [
|
||||
'/econ/roomInventory/room/92',
|
||||
'/econ/roomInventory/room/92/player',
|
||||
'/econ/roomInventoryItemTags/room/92',
|
||||
'/econ/roomOffer/room/92',
|
||||
'/econ/roomOffer/room/92/purchaseCounts',
|
||||
'/econ/roomGiftDropShops/room/92',
|
||||
]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`)
|
||||
expect(res.status, path).toBe(200)
|
||||
expect(await res.json(), path).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
|
||||
expect(anon.status).toBe(401)
|
||||
@@ -2110,6 +2128,12 @@ describe('econ endpoints', () => {
|
||||
'GET /api/storefronts/v3/giftdropstore/{id}',
|
||||
'GET /api/storefronts/v4/balance/{currencyType}',
|
||||
'GET /econ/customAvatarItems/v1/owned',
|
||||
'GET /econ/roomGiftDropShops/room/{roomId}',
|
||||
'GET /econ/roomInventory/room/{roomId}',
|
||||
'GET /econ/roomInventory/room/{roomId}/player',
|
||||
'GET /econ/roomInventoryItemTags/room/{roomId}',
|
||||
'GET /econ/roomOffer/room/{roomId}',
|
||||
'GET /econ/roomOffer/room/{roomId}/purchaseCounts',
|
||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
'POST /api/avatar/v2/gifts/consume',
|
||||
'POST /api/avatar/v2/set',
|
||||
|
||||
@@ -106,14 +106,15 @@ const NULL_CONNECTION_INFO = {
|
||||
|
||||
/**
|
||||
* The Photon applications the client connects to (`GET /player/connection-info`).
|
||||
* Temporary placeholders — move them to wrangler vars before they need to differ per
|
||||
* environment. `photonRegion` matches the value `roomInstanceFromRoom` stamps on every
|
||||
* Hardcoded temporarily — move them to wrangler vars before they need to differ per
|
||||
* environment (they are per-deployment ids, not secrets: the client receives all three
|
||||
* in the clear). `photonRegion` matches the value `roomInstanceFromRoom` stamps on every
|
||||
* instance, so the two can't disagree ('us' resolves to us-east1 for QoS).
|
||||
*/
|
||||
const PHOTON_APPS = {
|
||||
photonRealtimeAppId: '',
|
||||
photonVoiceAppId: '',
|
||||
photonChatAppId: '',
|
||||
photonRealtimeAppId: 'rf-8f322bdb',
|
||||
photonVoiceAppId: 'rf-6b4682e1',
|
||||
photonChatAppId: 'rf-55fae86e',
|
||||
photonRegion: 'us',
|
||||
} as const
|
||||
|
||||
@@ -1768,8 +1769,12 @@ const app = new Hono<App>()
|
||||
photonAuthToken,
|
||||
...PHOTON_APPS,
|
||||
photonRoomId,
|
||||
voiceConnectionInfo: null,
|
||||
voiceServerId: null,
|
||||
// Empty strings rather than null: there's no separate voice server either
|
||||
// way, and the client's decoder is likelier to accept a missing-value string
|
||||
// than a null on a string field. The presence payload's
|
||||
// NULL_CONNECTION_INFO keeps its nulls — that one never carries credentials.
|
||||
voiceConnectionInfo: '',
|
||||
voiceServerId: '',
|
||||
experiments: PHOTON_EXPERIMENTS,
|
||||
},
|
||||
error: null,
|
||||
|
||||
@@ -206,8 +206,8 @@ export const ConnectionInfo = z.object({
|
||||
photonChatAppId: z.string().describe('Photon Chat application id'),
|
||||
photonRegion: z.string().describe('Region id, matching a room instance’s `photonRegion`'),
|
||||
photonRoomId: z.string().describe('The caller’s current instance; empty when they’re in none'),
|
||||
voiceConnectionInfo: z.null().describe('Null — no separate voice server'),
|
||||
voiceServerId: z.null().describe('Null — no separate voice server'),
|
||||
voiceConnectionInfo: z.literal('').describe('Empty — no separate voice server'),
|
||||
voiceServerId: z.literal('').describe('Empty — no separate voice server'),
|
||||
experiments: ConnectionExperiments,
|
||||
})
|
||||
|
||||
|
||||
@@ -935,16 +935,18 @@ describe('auth-gated endpoints', () => {
|
||||
value: {
|
||||
// A signed JWT, not an opaque id — three base64url segments.
|
||||
photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
|
||||
photonRealtimeAppId: '',
|
||||
photonVoiceAppId: '',
|
||||
photonChatAppId: '',
|
||||
photonRealtimeAppId: 'rf-8f322bdb',
|
||||
photonVoiceAppId: 'rf-6b4682e1',
|
||||
photonChatAppId: 'rf-55fae86e',
|
||||
// Matches the region every room instance is stamped with.
|
||||
photonRegion: 'us',
|
||||
// The room the client is told to join has to be the one matchmaking placed
|
||||
// them in, or they end up alone in a room of their own.
|
||||
photonRoomId: matchmaked.roomInstance.photonRoomId,
|
||||
voiceConnectionInfo: null,
|
||||
voiceServerId: null,
|
||||
// Empty strings, not nulls — unlike the presence payload's connection fields,
|
||||
// which stay null (they never carry credentials).
|
||||
voiceConnectionInfo: '',
|
||||
voiceServerId: '',
|
||||
experiments: {
|
||||
networkTransformSyncInterval: 10,
|
||||
shouldUseUnreliableOnChange: false,
|
||||
@@ -976,9 +978,10 @@ describe('auth-gated endpoints', () => {
|
||||
'rn.env': string
|
||||
}
|
||||
expect(claims.sub).toBe('961')
|
||||
// Scoped to the realtime app the same response hands out — a placeholder empty
|
||||
// string until PHOTON_APPS moves to wrangler vars, so assert the two agree rather
|
||||
// than pinning the placeholder itself.
|
||||
// Scoped to the realtime app the same response hands out. Asserted as agreement
|
||||
// rather than a pinned literal: PHOTON_APPS is hardcoded until it moves to wrangler
|
||||
// vars, and a token minted for a different app than the client is handed is the bug
|
||||
// worth catching here.
|
||||
expect(claims.aud).toBe(body.value.photonRealtimeAppId)
|
||||
expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
|
||||
// The client is built against prod regardless of which environment we run in.
|
||||
|
||||
@@ -675,3 +675,11 @@ export const PhotonAccessTokenDto = z.object({
|
||||
export const PlayerDataDto = z.object({
|
||||
Data: z.string().describe('Always empty — no per-room player data is stored'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /rooms/{roomId}/experience/player` — the caller's per-room experience/progression
|
||||
* entries. Stubbed empty; the element shape is unknown until something stores one.
|
||||
*/
|
||||
export const RoomExperiencePlayer = z
|
||||
.array(z.unknown())
|
||||
.describe('Always empty — no per-room experience is tracked')
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getFeaturedRooms,
|
||||
getHotRooms,
|
||||
getInteraction,
|
||||
getOrCreateDormRoom,
|
||||
getPresence,
|
||||
getPublicRoomsByCreator,
|
||||
getRecommendedRooms,
|
||||
@@ -96,6 +97,7 @@ import {
|
||||
RoomBanEnvelope,
|
||||
RoomDto,
|
||||
RoomEnvelope,
|
||||
RoomExperiencePlayer,
|
||||
roomIdParam,
|
||||
RoomLookup,
|
||||
RoomResultEnvelope,
|
||||
@@ -845,6 +847,31 @@ const app = new Hono<App>()
|
||||
ownedRooms
|
||||
)
|
||||
|
||||
// The caller's own dorm, in the same shape `GET /rooms/{roomId}` serves — the client
|
||||
// renders it with the same code path. Gets-or-creates, exactly as entering a dorm
|
||||
// does (`match`), so a player who has never been to their dorm gets one here rather
|
||||
// than a 404; the id is stable from then on.
|
||||
.get(
|
||||
'/dormroom/me',
|
||||
describeRoute({
|
||||
tags: ['My rooms'],
|
||||
summary: 'The caller’s dorm',
|
||||
description: [
|
||||
'The caller’s personal dorm room, as `GET /rooms/{roomId}` would serve it —',
|
||||
'`SubRooms` re-attached, same DTO. The dorm is provisioned on first access (cloned',
|
||||
'from the seeded template dorm), so this returns a room for any authed caller and',
|
||||
'never 404s; calling it repeatedly returns the same dorm.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: { 200: json(RoomDto, 'The caller’s dorm'), 401: UNAUTHORIZED_RESPONSE },
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
return c.json(await getOrCreateDormRoom(c.env.DB, accountId))
|
||||
}
|
||||
)
|
||||
|
||||
// Public: the rooms a given account owns that are publicly viewable. No auth —
|
||||
// returns a bare array (empty when the account owns no public rooms).
|
||||
.get(
|
||||
@@ -2611,6 +2638,24 @@ const app = new Hono<App>()
|
||||
(c) => c.json({ Data: '' })
|
||||
)
|
||||
|
||||
// The caller's per-room experience/progression. Stub → empty list.
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}/experience/player',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'The caller’s per-room experience',
|
||||
description: [
|
||||
'Per-room experience/progression for the calling player. Nothing tracks any yet, so',
|
||||
'this is an empty list — which the client reads as “no progress in this room”, where',
|
||||
'a 404 would stall the room load. No auth, matching `playerdata/me`: the answer is',
|
||||
'the same for every caller until something writes here.',
|
||||
].join(' '),
|
||||
parameters: [roomIdParam],
|
||||
responses: { 200: json(RoomExperiencePlayer, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Single room by id. 404 when the room isn't in D1. Ignores the
|
||||
// include/unityAsset* query params.
|
||||
.get(
|
||||
|
||||
@@ -89,6 +89,18 @@ beforeAll(async () => {
|
||||
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
|
||||
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||
|
||||
// Accounts table (owned by the auth worker) — provisioning a dorm reads the username
|
||||
// to name the room. Seed the player `dormroom/me` provisions a fresh dorm for.
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS account (
|
||||
data TEXT NOT NULL,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 999, username: 'Dormer' }))
|
||||
.run()
|
||||
|
||||
// Relationship table (owned by the api worker) — `visitedby/:playerId` reads it to
|
||||
// check the caller is a friend of the player whose history they're asking for.
|
||||
await env.DB.prepare(
|
||||
@@ -129,6 +141,14 @@ describe('rooms endpoints', () => {
|
||||
expect(body.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
|
||||
})
|
||||
|
||||
// Stub. Registered (not 404) matters more than the body: the client asks for this on
|
||||
// room entry, and an unregistered path stalls the load rather than erroring visibly.
|
||||
it('GET /rooms/:id/experience/player returns [] for any room', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/92/experience/player`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -181,6 +201,41 @@ describe('rooms endpoints', () => {
|
||||
expect(other).toEqual([])
|
||||
})
|
||||
|
||||
it('GET /dormroom/me serves the caller’s own dorm in the room shape', async () => {
|
||||
// No token → 401. Without this the endpoint would hand out (and provision) a dorm
|
||||
// for whichever account a fallback picked.
|
||||
const noAuth = await SELF.fetch(`${ORIGIN}/dormroom/me`)
|
||||
expect(noAuth.status).toBe(401)
|
||||
|
||||
// Account 1 owns the seeded dorm (RoomId 1), served exactly as GET /rooms/1 does —
|
||||
// same DTO, SubRooms re-attached.
|
||||
const res = await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('1') })
|
||||
expect(res.status).toBe(200)
|
||||
const dorm = (await res.json()) as {
|
||||
RoomId: number
|
||||
IsDorm: boolean
|
||||
CreatorAccountId: number
|
||||
SubRooms: Array<{ UnitySceneId: string }>
|
||||
}
|
||||
expect(dorm).toMatchObject({ RoomId: 1, IsDorm: true, CreatorAccountId: 1 })
|
||||
expect(dorm.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
|
||||
expect(dorm).toEqual(await (await SELF.fetch(`${ORIGIN}/rooms/1`)).json())
|
||||
|
||||
// A player who has never entered their dorm gets one provisioned rather than a
|
||||
// 404, and it belongs to THEM — not the template dorm they were cloned from.
|
||||
const fresh = (await (
|
||||
await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('999') })
|
||||
).json()) as { RoomId: number; IsDorm: boolean; CreatorAccountId: number }
|
||||
expect(fresh).toMatchObject({ IsDorm: true, CreatorAccountId: 999 })
|
||||
expect(fresh.RoomId).not.toBe(1)
|
||||
|
||||
// Idempotent: the second call is the same dorm, not a second one.
|
||||
const again = (await (
|
||||
await SELF.fetch(`${ORIGIN}/dormroom/me`, { headers: await bearer('999') })
|
||||
).json()) as { RoomId: number }
|
||||
expect(again.RoomId).toBe(fresh.RoomId)
|
||||
})
|
||||
|
||||
// The website's "My rooms" list is a browser calling this worker from another origin,
|
||||
// so a response without CORS headers is one the browser throws away — and the page
|
||||
// can't tell that apart from the server being down. Pinned on the preflight too: the
|
||||
@@ -580,9 +635,7 @@ describe('rooms endpoints', () => {
|
||||
it('GET /rooms/hot?tag=community serves rooms the Coach account did not create', async () => {
|
||||
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
|
||||
const feed = async (): Promise<Feed> =>
|
||||
(await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)
|
||||
).json()) as Feed
|
||||
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)).json()) as Feed
|
||||
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
|
||||
|
||||
// No room carries a `community` tag, and every seeded room belongs to Coach
|
||||
@@ -2911,6 +2964,7 @@ describe('rooms endpoints', () => {
|
||||
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
|
||||
'GET /',
|
||||
'GET /XXXfeaturedrooms/current',
|
||||
'GET /dormroom/me',
|
||||
'GET /photon_access_token',
|
||||
'GET /rooms',
|
||||
'GET /rooms/base',
|
||||
@@ -2926,6 +2980,7 @@ describe('rooms endpoints', () => {
|
||||
'GET /rooms/visitedby/{playerId}',
|
||||
'GET /rooms/{roomId}',
|
||||
'GET /rooms/{roomId}/bans',
|
||||
'GET /rooms/{roomId}/experience/player',
|
||||
'GET /rooms/{roomId}/interactionby/me',
|
||||
'GET /rooms/{roomId}/playerdata/me',
|
||||
'GET /rooms/{roomId}/similar',
|
||||
|
||||
Reference in New Issue
Block a user