mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
more routes
This commit is contained in:
@@ -15,24 +15,26 @@ export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
data TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.AccountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Username'))) VIRTUAL
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON accounts (account_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower)`,
|
||||
]
|
||||
|
||||
/** Client-facing account shape (PascalCase, as the client expects). */
|
||||
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
|
||||
export interface Account {
|
||||
AccountId: number
|
||||
Username: string
|
||||
DisplayName: string
|
||||
ProfileImage: string
|
||||
IsJunior: boolean
|
||||
Platforms: number
|
||||
PersonalPronouns: number
|
||||
IdentityFlags: number
|
||||
CreatedAt: string
|
||||
accountId: number
|
||||
username: string
|
||||
displayName: string
|
||||
profileImage: string
|
||||
isJunior: boolean
|
||||
platforms: number
|
||||
personalPronouns: number
|
||||
identityFlags: number
|
||||
createdAt: string
|
||||
/** Set via POST /account/me/email; absent until the player provides one. */
|
||||
email?: string
|
||||
}
|
||||
|
||||
interface AccountRow {
|
||||
@@ -68,15 +70,15 @@ export function randomUsername(): string {
|
||||
*/
|
||||
export function defaultAccount(id: number, overrides: Partial<Account> = {}): Account {
|
||||
return {
|
||||
AccountId: id,
|
||||
Username: `Player${id}`,
|
||||
DisplayName: `Player${id}`,
|
||||
ProfileImage: 'DefaultProfileImage.jpg',
|
||||
IsJunior: false,
|
||||
Platforms: 0,
|
||||
PersonalPronouns: 0,
|
||||
IdentityFlags: 0,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
accountId: id,
|
||||
username: `Player${id}`,
|
||||
displayName: `Player${id}`,
|
||||
profileImage: 'DefaultProfileImage.jpg',
|
||||
isJunior: false,
|
||||
platforms: 0,
|
||||
personalPronouns: 0,
|
||||
identityFlags: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export async function updateAccount(
|
||||
overrides: Partial<Account>
|
||||
): Promise<Account> {
|
||||
const current = (await getAccount(db, id)) ?? defaultAccount(id)
|
||||
const updated: Account = { ...current, ...overrides, AccountId: id }
|
||||
const updated: Account = { ...current, ...overrides, accountId: id }
|
||||
const data = JSON.stringify(updated)
|
||||
const res = await db
|
||||
.prepare('UPDATE accounts SET data = ?2 WHERE account_id = ?1')
|
||||
@@ -136,8 +138,8 @@ export async function createAccount(
|
||||
.prepare('SELECT COALESCE(MAX(account_id), 1) + 1 AS next FROM accounts')
|
||||
.first<{ next: number }>()
|
||||
const id = row?.next ?? 2
|
||||
const username = overrides.Username ?? randomUsername()
|
||||
const account = defaultAccount(id, { Username: username, DisplayName: username, ...overrides })
|
||||
const username = overrides.username ?? randomUsername()
|
||||
const account = defaultAccount(id, { username, displayName: username, ...overrides })
|
||||
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(JSON.stringify(account)).run()
|
||||
return account
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createAccount, defaultAccount, getAccount, getAccountsByIds, updateAcco
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from './accounts-db'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
@@ -47,6 +48,24 @@ async function formField(c: Context<App>, name: string): Promise<string> {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored account into the public account DTO — the client's camelCase
|
||||
* shape, excluding private fields like `email` (surfaced only by /account/me).
|
||||
*/
|
||||
function toAccountDto(account: Account) {
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
username: account.username,
|
||||
displayName: account.displayName,
|
||||
profileImage: account.profileImage,
|
||||
isJunior: account.isJunior,
|
||||
platforms: account.platforms,
|
||||
personalPronouns: account.personalPronouns,
|
||||
identityFlags: account.identityFlags,
|
||||
createdAt: account.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -70,16 +89,15 @@ const app = new Hono<App>()
|
||||
if (id === null) return unauthorized(c)
|
||||
// Load the stored account, falling back to a synthesized default.
|
||||
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
|
||||
// `JuniorState` (an enum) and `ParentAccountId` are OMITTED when null —
|
||||
// `juniorState` (an enum) and `parentAccountId` are OMITTED when null —
|
||||
// emitting `"juniorState":null` makes the client's enum parser throw
|
||||
// ("Can't parse JSON to Enum format"). `Email`/`Phone`/`Birthday` are kept
|
||||
// as null (they aren't enums, so null is fine).
|
||||
// ("Can't parse JSON to Enum format"). `email`/`birthday` are kept as null
|
||||
// (they aren't enums, so null is fine).
|
||||
return c.json({
|
||||
...account,
|
||||
Email: null,
|
||||
Phone: null,
|
||||
Birthday: null,
|
||||
AvailableUsernameChanges: 1,
|
||||
...toAccountDto(account),
|
||||
email: account.email ?? null,
|
||||
birthday: null,
|
||||
availableUsernameChanges: 1,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,8 +113,8 @@ const app = new Hono<App>()
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
// Resolve stored accounts, synthesizing a default for any id not in the DB
|
||||
// so every requested id is present in the response.
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.AccountId, a]))
|
||||
return c.json(ids.map((id) => stored.get(id) ?? defaultAccount(id)))
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
|
||||
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
|
||||
})
|
||||
|
||||
.get('/account/:id/bio', (c) => {
|
||||
@@ -110,7 +128,7 @@ const app = new Hono<App>()
|
||||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||||
// Load the stored account, falling back to a synthesized default.
|
||||
return c.json((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId))
|
||||
return c.json(toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId)))
|
||||
})
|
||||
|
||||
// ---- Create --------------------------------------------------------------
|
||||
@@ -123,10 +141,10 @@ const app = new Hono<App>()
|
||||
// don't choose one initially).
|
||||
const platforms = Number.parseInt(platform, 10)
|
||||
const account = await createAccount(c.env.DB, {
|
||||
Platforms: Number.isNaN(platforms) ? 0 : platforms,
|
||||
platforms: Number.isNaN(platforms) ? 0 : platforms,
|
||||
})
|
||||
// TODO: also create a dorm Room/SubRoom for the new account.
|
||||
return c.json({ success: true, value: account })
|
||||
return c.json({ success: true, value: toAccountDto(account) })
|
||||
})
|
||||
|
||||
// ---- Parental control ----------------------------------------------------
|
||||
@@ -151,6 +169,26 @@ const app = new Hono<App>()
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// Set the player's email (persisted on the account row; surfaced by /account/me).
|
||||
.post('/account/me/email', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const email = (await formField(c, 'email')).trim()
|
||||
if (!email.includes('@')) return c.body(null, 400)
|
||||
await updateAccount(c.env.DB, id, { email })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// Set the player's identityFlags bitmask (persisted; surfaced by /account/me).
|
||||
.put('/account/me/identityflags', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10)
|
||||
if (Number.isNaN(identityFlags)) return c.body(null, 400)
|
||||
await updateAccount(c.env.DB, id, { identityFlags })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
.put('/account/me/bio', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
@@ -165,7 +203,7 @@ const app = new Hono<App>()
|
||||
if (!imageName) return c.body(null, 400)
|
||||
// Persist the new avatar key on the account row (the C# also fires an
|
||||
// AccountUpdate websocket — no notify binding here, so it's omitted).
|
||||
await updateAccount(c.env.DB, id, { ProfileImage: imageName })
|
||||
await updateAccount(c.env.DB, id, { profileImage: imageName })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ beforeAll(async () => {
|
||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
|
||||
await env.DB.batch([
|
||||
insert.bind(JSON.stringify({ AccountId: 0, Username: 'RecRoom', DisplayName: 'Rec Room' })),
|
||||
insert.bind(JSON.stringify({ AccountId: 1, Username: 'Coach', DisplayName: 'Coach' })),
|
||||
insert.bind(JSON.stringify({ accountId: 0, username: 'RecRoom', displayName: 'Rec Room' })),
|
||||
insert.bind(JSON.stringify({ accountId: 1, username: 'Coach', displayName: 'Coach' })),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -70,10 +70,10 @@ describe('public endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/123`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
AccountId: 123,
|
||||
Username: 'Player123',
|
||||
DisplayName: 'Player123',
|
||||
ProfileImage: 'DefaultProfileImage.jpg',
|
||||
accountId: 123,
|
||||
username: 'Player123',
|
||||
displayName: 'Player123',
|
||||
profileImage: 'DefaultProfileImage.jpg',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,12 +85,12 @@ describe('public endpoints', () => {
|
||||
test('GET /account/bulk resolves stored accounts and synthesizes the rest', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/bulk?id=1&id=2,3`)
|
||||
expect(res.status).toBe(200)
|
||||
const accounts = (await res.json()) as Array<{ AccountId: number; Username: string }>
|
||||
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
|
||||
// Every requested id is present and in order.
|
||||
expect(accounts.map((a) => a.AccountId)).toEqual([1, 2, 3])
|
||||
expect(accounts.map((a) => a.accountId)).toEqual([1, 2, 3])
|
||||
// id 1 is the seeded Coach account; 2 and 3 fall back to synthesized defaults.
|
||||
expect(accounts[0].Username).toBe('Coach')
|
||||
expect(accounts[1].Username).toBe('Player2')
|
||||
expect(accounts[0].username).toBe('Coach')
|
||||
expect(accounts[1].username).toBe('Player2')
|
||||
})
|
||||
|
||||
test('GET /account/:id/bio returns an empty bio', async () => {
|
||||
@@ -103,20 +103,20 @@ describe('public endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
success: boolean
|
||||
value: { AccountId: number; Username: string; DisplayName: string }
|
||||
value: { accountId: number; username: string; displayName: string }
|
||||
}
|
||||
expect(body.success).toBe(true)
|
||||
// Id is allocated above the seeded system accounts (0, 1).
|
||||
expect(body.value.AccountId).toBeGreaterThanOrEqual(2)
|
||||
expect(body.value.accountId).toBeGreaterThanOrEqual(2)
|
||||
// Username is auto-assigned (not the synthesized "Player<id>" fallback) and
|
||||
// the display name mirrors it.
|
||||
expect(body.value.Username).not.toMatch(/^Player\d+$/)
|
||||
expect(body.value.Username.length).toBeGreaterThan(0)
|
||||
expect(body.value.DisplayName).toBe(body.value.Username)
|
||||
expect(body.value.username).not.toMatch(/^Player\d+$/)
|
||||
expect(body.value.username.length).toBeGreaterThan(0)
|
||||
expect(body.value.displayName).toBe(body.value.username)
|
||||
// It's retrievable afterwards.
|
||||
const lookup = await exports.default.fetch(`${ORIGIN}/account/${body.value.AccountId}`)
|
||||
const found = (await lookup.json()) as { Username: string }
|
||||
expect(found.Username).toBe(body.value.Username)
|
||||
const lookup = await exports.default.fetch(`${ORIGIN}/account/${body.value.accountId}`)
|
||||
const found = (await lookup.json()) as { username: string }
|
||||
expect(found.username).toBe(body.value.username)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,15 +137,21 @@ describe('auth-gated endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer() })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
// Account JSON is camelCase (the client's AccountDTO), not the PascalCase we
|
||||
// store internally.
|
||||
expect(body).toMatchObject({
|
||||
AccountId: 42,
|
||||
Username: 'Player42',
|
||||
AvailableUsernameChanges: 1,
|
||||
accountId: 42,
|
||||
username: 'Player42',
|
||||
personalPronouns: 0,
|
||||
identityFlags: 0,
|
||||
availableUsernameChanges: 1,
|
||||
})
|
||||
// JuniorState + ParentAccountId must be omitted when null, not emitted as
|
||||
// null, or the client's enum parser throws on `juniorState`.
|
||||
expect('JuniorState' in body).toBe(false)
|
||||
expect('ParentAccountId' in body).toBe(false)
|
||||
// juniorState + parentAccountId must be omitted when null, not emitted as
|
||||
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
|
||||
// part of the shape.
|
||||
expect('juniorState' in body).toBe(false)
|
||||
expect('parentAccountId' in body).toBe(false)
|
||||
expect('phone' in body).toBe(false)
|
||||
})
|
||||
|
||||
test('GET /parentalcontrol/me returns the flags', async () => {
|
||||
@@ -196,6 +202,64 @@ describe('auth-gated endpoints', () => {
|
||||
|
||||
// The stored value is returned by the self account (no hardcoded override).
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('777') })
|
||||
expect(((await me.json()) as { ProfileImage: string }).ProfileImage).toBe('deadbeef.jpg')
|
||||
expect(((await me.json()) as { profileImage: string }).profileImage).toBe('deadbeef.jpg')
|
||||
})
|
||||
|
||||
test('PUT /account/me/identityflags 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/identityflags`, {
|
||||
...form({ identityFlags: '384' }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('PUT /account/me/identityflags 400s on a non-numeric value', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/identityflags`, {
|
||||
...form({ identityFlags: 'nope' }),
|
||||
headers: { ...(await bearer('889')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('PUT /account/me/identityflags persists the flags, surfaced by /account/me', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/identityflags`, {
|
||||
...form({ identityFlags: '384' }),
|
||||
headers: { ...(await bearer('889')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('889') })
|
||||
expect(((await me.json()) as { identityFlags: number }).identityFlags).toBe(384)
|
||||
})
|
||||
|
||||
test('POST /account/me/email 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email: 'a@b.com' }),
|
||||
method: 'POST',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /account/me/email 400s on a malformed email', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email: 'notanemail' }),
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('888')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /account/me/email persists the email, surfaced by /account/me', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email: 'ners@recroom.com' }),
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('888')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// The stored email is now returned by the self account (was a null stub).
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('888') })
|
||||
expect(((await me.json()) as { email: string }).email).toBe('ners@recroom.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -445,7 +445,7 @@ const app = new Hono<App>({ strict: false })
|
||||
// account row (a JSON blob in the shared accounts table) so it sticks.
|
||||
if (savedImageType === SavedImageType.ProfileThumbnail) {
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE accounts SET data = json_set(data, '$.ProfileImage', ?2) WHERE account_id = ?1"
|
||||
"UPDATE accounts SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
|
||||
)
|
||||
.bind(id, name)
|
||||
.run()
|
||||
|
||||
@@ -44,12 +44,12 @@ beforeAll(async () => {
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
data TEXT NOT NULL,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.AccountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Username'))) VIRTUAL
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ AccountId: 42, Username: 'Tester', ProfileImage: 'DefaultProfileImage.jpg' }))
|
||||
.bind(JSON.stringify({ accountId: 42, username: 'Tester', profileImage: 'DefaultProfileImage.jpg' }))
|
||||
.run()
|
||||
})
|
||||
|
||||
@@ -374,11 +374,11 @@ describe('images', () => {
|
||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||
expect(ImageName).toMatch(/^[0-9a-f]+\.jpg$/)
|
||||
|
||||
// The account row now points its ProfileImage at the uploaded key.
|
||||
// The account row now points its profileImage at the uploaded key.
|
||||
const row = await env.DB.prepare('SELECT data FROM accounts WHERE account_id = 42').first<{
|
||||
data: string
|
||||
}>()
|
||||
expect(JSON.parse(row!.data).ProfileImage).toBe(ImageName)
|
||||
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
|
||||
})
|
||||
|
||||
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
data TEXT NOT NULL,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.AccountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Username'))) VIRTUAL
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON accounts (account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower);
|
||||
|
||||
-- Seed the system account (uid 0) and Coach (uid 1).
|
||||
INSERT OR IGNORE INTO accounts (data) VALUES ('{"AccountId":0,"Username":"RecRoom","DisplayName":"Rec Room","ProfileImage":"DefaultProfileImage.jpg","IsJunior":false,"Platforms":0,"PersonalPronouns":0,"IdentityFlags":0,"CreatedAt":"2016-01-01T00:00:00Z"}');
|
||||
INSERT OR IGNORE INTO accounts (data) VALUES ('{"AccountId":1,"Username":"Coach","DisplayName":"Coach","ProfileImage":"DefaultProfileImage.jpg","IsJunior":false,"Platforms":0,"PersonalPronouns":0,"IdentityFlags":0,"CreatedAt":"2016-01-01T00:00:00Z"}');
|
||||
INSERT OR IGNORE INTO accounts (data) VALUES ('{"accountId":0,"username":"RecRoom","displayName":"Rec Room","profileImage":"DefaultProfileImage.jpg","isJunior":false,"platforms":0,"personalPronouns":0,"identityFlags":0,"createdAt":"2016-01-01T00:00:00Z"}');
|
||||
INSERT OR IGNORE INTO accounts (data) VALUES ('{"accountId":1,"username":"Coach","displayName":"Coach","profileImage":"DefaultProfileImage.jpg","isJunior":false,"platforms":0,"personalPronouns":0,"identityFlags":0,"createdAt":"2016-01-01T00:00:00Z"}');
|
||||
|
||||
@@ -15,24 +15,24 @@ export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
data TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.AccountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Username'))) VIRTUAL
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON accounts (account_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower)`,
|
||||
]
|
||||
|
||||
/** Client-facing account shape (PascalCase, as the client expects). */
|
||||
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
|
||||
export interface Account {
|
||||
AccountId: number
|
||||
Username: string
|
||||
DisplayName: string
|
||||
ProfileImage: string
|
||||
IsJunior: boolean
|
||||
Platforms: number
|
||||
PersonalPronouns: number
|
||||
IdentityFlags: number
|
||||
CreatedAt: string
|
||||
accountId: number
|
||||
username: string
|
||||
displayName: string
|
||||
profileImage: string
|
||||
isJunior: boolean
|
||||
platforms: number
|
||||
personalPronouns: number
|
||||
identityFlags: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface AccountRow {
|
||||
@@ -68,15 +68,15 @@ export function randomUsername(): string {
|
||||
*/
|
||||
export function defaultAccount(id: number, overrides: Partial<Account> = {}): Account {
|
||||
return {
|
||||
AccountId: id,
|
||||
Username: `Player${id}`,
|
||||
DisplayName: `Player${id}`,
|
||||
ProfileImage: 'DefaultProfileImage.jpg',
|
||||
IsJunior: false,
|
||||
Platforms: 0,
|
||||
PersonalPronouns: 0,
|
||||
IdentityFlags: 0,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
accountId: id,
|
||||
username: `Player${id}`,
|
||||
displayName: `Player${id}`,
|
||||
profileImage: 'DefaultProfileImage.jpg',
|
||||
isJunior: false,
|
||||
platforms: 0,
|
||||
personalPronouns: 0,
|
||||
identityFlags: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -112,8 +112,8 @@ export async function createAccount(
|
||||
.prepare('SELECT COALESCE(MAX(account_id), 1) + 1 AS next FROM accounts')
|
||||
.first<{ next: number }>()
|
||||
const id = row?.next ?? 2
|
||||
const username = overrides.Username ?? randomUsername()
|
||||
const account = defaultAccount(id, { Username: username, DisplayName: username, ...overrides })
|
||||
const username = overrides.username ?? randomUsername()
|
||||
const account = defaultAccount(id, { username, displayName: username, ...overrides })
|
||||
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(JSON.stringify(account)).run()
|
||||
return account
|
||||
}
|
||||
|
||||
+19
-18
@@ -109,20 +109,14 @@ const app = new Hono<App>()
|
||||
// EAC challenge — a fresh GUID, JSON-quoted, served as plain text.
|
||||
.get('/eac/challenge', (c) => c.text(`"AA=="`))
|
||||
|
||||
// Cached logins for a platform id. No DB binding yet — always empty.
|
||||
// Cached logins for a platform id. No CachedLogins storage yet, so there's never
|
||||
// a cached account — return []. The client then goes through a fresh login /
|
||||
// create_account instead of auto-logging into a stub account.
|
||||
.get('/cachedlogin/forplatformid/:platform/:id', (c) => {
|
||||
const { platform, id } = c.req.param()
|
||||
logger.info('cached login lookup', { platform, id })
|
||||
// TODO: query CachedLogins once a DB binding exists.
|
||||
return c.json([
|
||||
{
|
||||
accountId: 1,
|
||||
platform: '0',
|
||||
platformId: '0',
|
||||
lastLoginTime: '2026-06-10T00:00:00Z',
|
||||
requirePassword: false,
|
||||
},
|
||||
])
|
||||
// TODO: query CachedLogins once they're persisted.
|
||||
return c.json([])
|
||||
})
|
||||
|
||||
// Bulk cached-login lookup by platform id (friends resolution). The client
|
||||
@@ -141,17 +135,24 @@ const app = new Hono<App>()
|
||||
const platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
|
||||
|
||||
// grant_type=create_account mints + persists a brand-new account (with an
|
||||
// auto-assigned random username — players don't choose one initially). The
|
||||
// token's `sub` is the new account's id. Otherwise use the posted account_id,
|
||||
// falling back to "1" (the cachedlogin stub hands the client account 1).
|
||||
// auto-assigned random username — players don't choose one initially) and the
|
||||
// token's `sub` is its id. Otherwise the request MUST post a valid account_id —
|
||||
// never fall back to a stub account (issuing account 1 to anyone would be bad).
|
||||
let accountId: string
|
||||
if (grantType === 'create_account') {
|
||||
const account = await createAccount(c.env.DB, { Platforms: platformInt || 0 })
|
||||
accountId = String(account.AccountId)
|
||||
const account = await createAccount(c.env.DB, { platforms: platformInt || 0 })
|
||||
accountId = String(account.accountId)
|
||||
// Place the new player in Orientation (they don't matchmake into it).
|
||||
await placeNewPlayerInOrientation(c.env, account.AccountId)
|
||||
await placeNewPlayerInOrientation(c.env, account.accountId)
|
||||
} else {
|
||||
accountId = typeof body.account_id === 'string' && body.account_id ? body.account_id : '1'
|
||||
const posted = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
if (!/^\d+$/.test(posted)) {
|
||||
return c.json(
|
||||
{ error: 'invalid_request', error_description: 'account_id is required' },
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = posted
|
||||
}
|
||||
|
||||
const accessToken = await generateToken(accountId, platformId, platform)
|
||||
|
||||
@@ -67,11 +67,10 @@ describe('auth worker routes', () => {
|
||||
expect(await res.text()).toBe('"AA=="')
|
||||
})
|
||||
|
||||
test('GET /cachedlogin/forplatformid/:platform/:id returns a cached login', async () => {
|
||||
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/abc123`)
|
||||
expect(res.status).toBe(200)
|
||||
const logins = (await res.json()) as Array<{ accountId: number }>
|
||||
expect(logins[0]).toMatchObject({ accountId: 1 })
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
|
||||
@@ -107,19 +106,19 @@ describe('auth worker routes', () => {
|
||||
expect(payload.scope).toContain('rn.api')
|
||||
})
|
||||
|
||||
test('POST /connect/token falls back to account 1 when no account_id is posted', async () => {
|
||||
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
|
||||
expect(res.status).toBe(200)
|
||||
const { access_token } = (await res.json()) as { access_token: string }
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(
|
||||
Uint8Array.from(
|
||||
atob(access_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')),
|
||||
(ch) => ch.charCodeAt(0)
|
||||
)
|
||||
)
|
||||
) as { sub: string }
|
||||
expect(payload.sub).toBe('1')
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()) as { error: string }).toMatchObject({ error: 'invalid_request' })
|
||||
})
|
||||
|
||||
test('POST /connect/token 400s on a non-numeric account_id', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'account_id=notanumber',
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /connect/token grant_type=create_account persists a new account', async () => {
|
||||
@@ -132,8 +131,8 @@ describe('auth worker routes', () => {
|
||||
.bind(sub)
|
||||
.first<{ data: string }>()
|
||||
expect(row).not.toBeNull()
|
||||
const account = JSON.parse(row!.data) as { Username: string }
|
||||
expect(account.Username).not.toMatch(/^Player\d+$/)
|
||||
const account = JSON.parse(row!.data) as { username: string }
|
||||
expect(account.username).not.toMatch(/^Player\d+$/)
|
||||
})
|
||||
|
||||
test('POST /connect/token create_account seeds the new player into Orientation', async () => {
|
||||
|
||||
@@ -14,8 +14,8 @@ export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
data TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.AccountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Username'))) VIRTUAL
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON accounts (account_id)`,
|
||||
]
|
||||
|
||||
@@ -19,7 +19,7 @@ const ORIGIN = 'https://example.com'
|
||||
beforeAll(async () => {
|
||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ AccountId: 42, Username: 'Tester', DisplayName: 'Tester' }))
|
||||
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
||||
.run()
|
||||
})
|
||||
|
||||
|
||||
@@ -78,6 +78,26 @@ export async function cloneRoom(
|
||||
return cloned
|
||||
}
|
||||
|
||||
/** Set a room's Description in place (the caller is responsible for the owner check). */
|
||||
export async function setRoomDescription(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
description: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare("UPDATE rooms SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1")
|
||||
.bind(roomId, description)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Set a room's Name in place (the caller checks ownership + name uniqueness first). */
|
||||
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
|
||||
await db
|
||||
.prepare("UPDATE rooms SET data = json_set(data, '$.Name', ?2) WHERE room_id = ?1")
|
||||
.bind(roomId, name)
|
||||
.run()
|
||||
}
|
||||
|
||||
interface RoomRow {
|
||||
data: string
|
||||
}
|
||||
@@ -339,19 +359,21 @@ export async function getHotRooms(
|
||||
/**
|
||||
* 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.
|
||||
* engagement. Returns a paginated `{ Results, TotalResults }` (the client's
|
||||
* RoomSimilarity source expects an object, not a bare array); empty if the target
|
||||
* isn't in D1 or is untagged. Small dataset, so done in memory.
|
||||
*/
|
||||
export async function getSimilarRooms(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<Room[]> {
|
||||
): Promise<{ Results: Room[]; TotalResults: number }> {
|
||||
const empty = { Results: [] as Room[], TotalResults: 0 }
|
||||
const target = await getRoomById(db, roomId)
|
||||
if (!target) return []
|
||||
if (!target) return empty
|
||||
const targetTags = new Set(roomTags(target))
|
||||
if (targetTags.size === 0) return []
|
||||
if (targetTags.size === 0) return empty
|
||||
|
||||
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
|
||||
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
|
||||
@@ -374,7 +396,8 @@ export async function getSimilarRooms(
|
||||
hotScore(b.room) - hotScore(a.room) ||
|
||||
roomIdOf(a.room) - roomIdOf(b.room)
|
||||
)
|
||||
return scored.slice(skip, skip + take).map((x) => x.room)
|
||||
const rooms = scored.map((x) => x.room)
|
||||
return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
getSimilarRooms,
|
||||
getVisitedRooms,
|
||||
searchRooms,
|
||||
setRoomDescription,
|
||||
setRoomName,
|
||||
toggleCheer,
|
||||
toggleFavorite,
|
||||
} from './rooms-db'
|
||||
@@ -92,6 +94,22 @@ function unauthorized(c: Context<App>) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
|
||||
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
|
||||
*/
|
||||
function roomResult(
|
||||
c: Context<App>,
|
||||
fields: { Success: boolean; Value?: unknown; ErrorId?: string; Error?: string }
|
||||
) {
|
||||
return c.json({
|
||||
Success: fields.Success,
|
||||
Value: fields.Value ?? null,
|
||||
ErrorId: fields.ErrorId ?? null,
|
||||
Error: fields.Error ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Client envelope for room clone results: `{ success, error, value }`. */
|
||||
function cloneResult(c: Context<App>, value: unknown, error = '') {
|
||||
return c.json({ success: error === '', error, value })
|
||||
@@ -273,8 +291,87 @@ const app = new Hono<App>()
|
||||
return cloneResult(c, room)
|
||||
})
|
||||
|
||||
// Update a room's description. Auth-gated (401) and owner-only. Business results
|
||||
// use the `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||
.put('/rooms/:roomId{[0-9]+}/description', 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 roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (room.CreatorAccountId !== accountId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
Error: 'You are not the owner of this room!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const description = typeof body.description === 'string' ? body.description : ''
|
||||
await setRoomDescription(c.env.DB, roomId, description)
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
|
||||
// Rename a room. Auth-gated (401) and owner-only; the new name must be non-empty
|
||||
// and not already taken by another room. Business results use the
|
||||
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||
// NOTE: the ErrorId strings (besides Rooms.DoesntExist) are best guesses.
|
||||
.put('/rooms/:roomId{[0-9]+}/name', 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 roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (room.CreatorAccountId !== accountId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
Error: 'You are not the owner of this room!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (name === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidName',
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
|
||||
// Reject if a different room already uses this name (case-insensitive).
|
||||
const existing = await getRoomByName(c.env.DB, name)
|
||||
if (existing && existing.RoomId !== roomId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.AlreadyExists',
|
||||
Error: 'A room with that name already exists!',
|
||||
})
|
||||
}
|
||||
|
||||
await setRoomName(c.env.DB, roomId, name)
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
|
||||
// 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.
|
||||
// defaults to 100). Returns `{ Results, TotalResults }`; 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
|
||||
|
||||
@@ -289,28 +289,32 @@ describe('rooms endpoints', () => {
|
||||
expect(body.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/similar returns a bare array of tag-sharing rooms (excluding self)', async () => {
|
||||
it('GET /rooms/:id/similar returns { Results, TotalResults } 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)
|
||||
const body = (await res.json()) as {
|
||||
Results: Array<{ RoomId: number; Tags?: Array<{ Tag: string }> }>
|
||||
TotalResults: number
|
||||
}
|
||||
expect(body.Results.length).toBeGreaterThan(0)
|
||||
expect(body.TotalResults).toBeGreaterThanOrEqual(body.Results.length)
|
||||
// Never includes the target room itself.
|
||||
expect(body.some((r) => r.RoomId === 2)).toBe(false)
|
||||
expect(body.Results.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)
|
||||
expect(body.Results.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)
|
||||
const body = (await res.json()) as { Results: unknown[]; TotalResults: number }
|
||||
expect(body.Results.length).toBeLessThanOrEqual(3)
|
||||
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/similar returns [] for a room not in D1', async () => {
|
||||
it('GET /rooms/:id/similar returns an empty result 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([])
|
||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/clone clones a base room into a new owned room', async () => {
|
||||
@@ -401,6 +405,73 @@ describe('rooms endpoints', () => {
|
||||
expect(missing).toMatchObject({ success: false, value: null })
|
||||
})
|
||||
|
||||
const putForm = async (path: string, fields: Record<string, string>, sub?: string) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...(sub ? await bearer(sub) : {}),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
|
||||
// Room-mutation envelope helper.
|
||||
type RoomResult = { Success: boolean; Value: unknown; ErrorId: string | null; Error: string | null }
|
||||
const bodyOf = async (res: Response) => (await res.json()) as RoomResult
|
||||
|
||||
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)
|
||||
// Not the owner (RecCenter is owned by account 1) → 200 envelope, Success:false.
|
||||
expect(await bodyOf(await putForm('/rooms/2/description', { description: 'x' }, '999'))).toMatchObject(
|
||||
{ Success: false, ErrorId: 'Rooms.NotOwner' }
|
||||
)
|
||||
// Unknown room → Rooms.DoesntExist envelope.
|
||||
expect(await bodyOf(await putForm('/rooms/99999/description', { description: 'x' }, '1'))).toMatchObject(
|
||||
{ Success: false, ErrorId: 'Rooms.DoesntExist', Error: 'This room does not exist!' }
|
||||
)
|
||||
|
||||
// Owner updates it, and it persists.
|
||||
const ok = await putForm('/rooms/2/description', { description: 'blah blah blah' }, '1')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await bodyOf(ok)).toMatchObject({ Success: true, Value: null, ErrorId: null, Error: null })
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Description: string }
|
||||
expect(room.Description).toBe('blah blah blah')
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {
|
||||
// No token → 401 (auth gate).
|
||||
expect((await putForm('/rooms/2/name', { name: 'Whatever' })).status).toBe(401)
|
||||
// Wrong owner / unknown room → Success:false envelopes.
|
||||
expect(await bodyOf(await putForm('/rooms/2/name', { name: 'Whatever' }, '999'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
})
|
||||
expect(await bodyOf(await putForm('/rooms/99999/name', { name: 'Whatever' }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
})
|
||||
// Empty name → Success:false.
|
||||
expect(await bodyOf(await putForm('/rooms/2/name', { name: ' ' }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidName',
|
||||
})
|
||||
// A name already used by a different room (GoldenTrophy is room 12).
|
||||
expect(await bodyOf(await putForm('/rooms/2/name', { name: 'GoldenTrophy' }, '1'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.AlreadyExists',
|
||||
Error: 'A room with that name already exists!',
|
||||
})
|
||||
|
||||
// Owner renames to a free name, and it persists (findable by the new name).
|
||||
const ok = await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1')
|
||||
expect(await bodyOf(ok)).toMatchObject({ Success: true })
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms?name=RenamedCenter`)).json()) as {
|
||||
RoomId: number
|
||||
}
|
||||
expect(room.RoomId).toBe(2)
|
||||
})
|
||||
|
||||
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