mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
[roomcomments] implement basic room comments
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
/**
|
||||
* Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value with
|
||||
* `await env.JWT_SECRET.get()`; every worker binds the same store, so tokens signed by
|
||||
* `auth` verify here.
|
||||
*/
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
/**
|
||||
* Shared `recflare` DB. This worker owns the `room_comment` table (schema/migration in
|
||||
* `migrations/`, mirrored by `ROOM_COMMENT_SCHEMA_DDL` in `@repo/domain`); every other
|
||||
* table on the database belongs to another worker.
|
||||
*/
|
||||
DB: D1Database
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
export type Variables = SharedHonoVariables
|
||||
|
||||
export interface App extends HonoApp {
|
||||
Bindings: Env
|
||||
Variables: Variables
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the roomcomments worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/econ/match workers: a reverse-engineered protocol,
|
||||
* lenient handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||
}
|
||||
|
||||
/**
|
||||
* A form-encoded request body. The client posts `application/x-www-form-urlencoded`; Hono's
|
||||
* `parseBody()` also reads multipart, so both are documented on the one body.
|
||||
*/
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const f = toOpenApiSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': { schema: f },
|
||||
'multipart/form-data': { schema: f },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The empty-body 401 the auth-gated routes return. */
|
||||
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
// ---- Response schemas ------------------------------------------------------
|
||||
|
||||
/** `GET /` — the root health check. */
|
||||
export const HealthResponse = z.object({
|
||||
service: z.literal('roomcomments'),
|
||||
status: z.literal('ok'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One comment as the client reads it. `PositionX/Y/Z` are NUMBERS on the way out even
|
||||
* though the create body posts them as text — a quoted float fails the client's parser.
|
||||
*/
|
||||
export const RoomCommentEntry = z.object({
|
||||
CommentId: z.int().describe('Autoincrement id; also the `minId` cursor for the read'),
|
||||
RoomId: z.int(),
|
||||
SubRoomId: z.int().describe('The subroom whose scene the comment is pinned in'),
|
||||
AccountId: z.int().describe('The player who wrote it'),
|
||||
CreatedAt: z.string().describe('ISO-8601 UTC'),
|
||||
Message: z.string(),
|
||||
Style: z.int().describe('The bubble style the client rendered it with'),
|
||||
Unread: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'Always true. Read state rather than a per-viewer flag, and nothing marks a comment read — the create response says true for the author’s own new comment too.'
|
||||
),
|
||||
PositionX: z.number(),
|
||||
PositionY: z.number(),
|
||||
PositionZ: z.number(),
|
||||
})
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The form-encoded create the client actually sends, e.g.
|
||||
* `message=sdf&subRoomId=1307&style=0&positionX=-0.4979804&positionY=1.568297&positionZ=-0.05002981`.
|
||||
*/
|
||||
export const CommentCreateBody = z.object({
|
||||
message: z.string().describe('The comment text; an empty message is rejected'),
|
||||
subRoomId: z.string().describe('The subroom to pin it in (integer, as text)'),
|
||||
style: z.string().optional().describe('Bubble style (integer, as text); defaults to 0'),
|
||||
positionX: z.string().optional().describe('Scene position (float, as text); defaults to 0'),
|
||||
positionY: z.string().optional().describe('Scene position (float, as text); defaults to 0'),
|
||||
positionZ: z.string().optional().describe('Scene position (float, as text); defaults to 0'),
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { createRoomComment, DEFAULT_COMMENT_COUNT, getRoomComments } from '@repo/domain'
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
AUTHED,
|
||||
CommentCreateBody,
|
||||
form,
|
||||
HealthResponse,
|
||||
json,
|
||||
RoomCommentEntry,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* Room Comments Worker. Serves the notes a player pins in a room's scene — a message, a
|
||||
* bubble style and the point in the subroom it floats at — which everyone standing there
|
||||
* sees.
|
||||
*
|
||||
* The read is deliberately NOT gated: a comment is a fixture of the room, visible to
|
||||
* whoever walks in, and the client fetches the list on load. Writing needs a token, since
|
||||
* the comment is signed with the author's account id.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the account id from a Bearer token. Returns `null` when the header is missing,
|
||||
* the token is invalid, or the `sub` claim isn't an integer.
|
||||
*/
|
||||
async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||
function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/** A path/query integer, or null when it's absent or not a number. */
|
||||
function intOrNull(value: string | undefined): number | null {
|
||||
if (value === undefined || value.trim() === '') return null
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : null
|
||||
}
|
||||
|
||||
/** A form field as a float. Unparseable text (and an absent field) is 0, not NaN. */
|
||||
function floatOrZero(value: unknown): number {
|
||||
if (typeof value !== 'string') return 0
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest comment that is stored. The client's own box stops well short of this; the
|
||||
* cap is here so a hand-rolled request can't park a megabyte in a room.
|
||||
*/
|
||||
const MAX_COMMENT_LENGTH = 1000
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
// middleware
|
||||
(c, next) =>
|
||||
useWorkersLogger(c.env.NAME, {
|
||||
environment: c.env.ENVIRONMENT,
|
||||
release: c.env.SENTRY_RELEASE,
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
// Root health check.
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Service'],
|
||||
summary: 'Health check',
|
||||
description: 'Liveness probe for the roomcomments worker. No auth.',
|
||||
responses: { 200: json(HealthResponse, 'Service is up') },
|
||||
}),
|
||||
(c) => c.json({ service: 'roomcomments', status: 'ok' })
|
||||
)
|
||||
|
||||
// A room's comments, newest first.
|
||||
.get(
|
||||
'/comments/get/:roomId',
|
||||
describeRoute({
|
||||
tags: ['Room Comments'],
|
||||
summary: 'A room’s comments',
|
||||
description: [
|
||||
'The comments pinned in a room, newest first. Public — a comment is a fixture of the',
|
||||
'room and the client fetches the list on load, so no token is needed. `Unread` is',
|
||||
'always true; nothing marks a comment read.',
|
||||
'',
|
||||
'`minId` is an EXCLUSIVE cursor, which is why the client’s "give me everything"',
|
||||
'sentinel is `-1` rather than `0`: a client holding comments up to id N polls with',
|
||||
'`minId=N` and gets only what was written since. `count` caps the page (default',
|
||||
`${DEFAULT_COMMENT_COUNT}, max 500\`); because the order is newest-first, a fresh client`,
|
||||
'asking a busy room for 100 gets the 100 that are actually on the wall rather than the',
|
||||
'oldest hundred.',
|
||||
'',
|
||||
'An unknown room simply has no comments — `[]`, not a 404.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'roomId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'integer' },
|
||||
description: 'The room to read',
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
in: 'query',
|
||||
schema: { type: 'integer' },
|
||||
description: `How many to serve (default ${DEFAULT_COMMENT_COUNT}, clamped to 1–500)`,
|
||||
},
|
||||
{
|
||||
name: 'minId',
|
||||
in: 'query',
|
||||
schema: { type: 'integer' },
|
||||
description: 'Exclusive id cursor; `-1` (the default) serves the newest page',
|
||||
},
|
||||
{
|
||||
name: 'subRoomId',
|
||||
in: 'query',
|
||||
schema: { type: 'integer' },
|
||||
description: 'Narrow to one subroom; omitted, the whole room’s comments are served',
|
||||
},
|
||||
],
|
||||
responses: { 200: json(RoomCommentEntry.array(), 'The room’s comments, newest first') },
|
||||
}),
|
||||
async (c) => {
|
||||
const roomId = intOrNull(c.req.param('roomId'))
|
||||
if (roomId === null) return c.json([])
|
||||
|
||||
return c.json(
|
||||
await getRoomComments(c.env.DB, roomId, {
|
||||
count: intOrNull(c.req.query('count')) ?? undefined,
|
||||
minId: intOrNull(c.req.query('minId')) ?? undefined,
|
||||
subRoomId: intOrNull(c.req.query('subRoomId')),
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Leave a comment in a room. Auth-gated: the comment is signed with the caller's id.
|
||||
.post(
|
||||
'/comments/create/:roomId',
|
||||
describeRoute({
|
||||
tags: ['Room Comments'],
|
||||
summary: 'Leave a comment in a room',
|
||||
description: [
|
||||
'Pins a comment in a subroom’s scene at the given point. The author is the bearer',
|
||||
'token’s account — the body carries no account id.',
|
||||
'',
|
||||
'Answers the created comment itself, so the client can render the bubble it just placed',
|
||||
'without re-fetching the list. `Unread` is true on it like everywhere else — it is read',
|
||||
'state, not a per-viewer flag, and the author’s own new comment is no exception.',
|
||||
'',
|
||||
'`positionX/Y/Z` arrive as a C# float’s round-trip text and go back out as numbers.',
|
||||
`A blank \`message\` or a missing \`subRoomId\` is a 400; longer than ${MAX_COMMENT_LENGTH}`,
|
||||
'characters is truncated rather than rejected.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'roomId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'integer' },
|
||||
description: 'The room to comment in',
|
||||
},
|
||||
],
|
||||
requestBody: form(CommentCreateBody, 'The comment, form-encoded as the client posts it'),
|
||||
responses: {
|
||||
200: json(RoomCommentEntry, 'The comment as stored'),
|
||||
400: { description: 'Unusable room id, blank message, or missing subroom (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return unauthorized(c)
|
||||
|
||||
const roomId = intOrNull(c.req.param('roomId'))
|
||||
if (roomId === null) return c.body(null, 400)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
|
||||
const subRoomId = intOrNull(typeof body.subRoomId === 'string' ? body.subRoomId : undefined)
|
||||
if (subRoomId === null) return c.body(null, 400)
|
||||
|
||||
const message = (typeof body.message === 'string' ? body.message : '')
|
||||
.trim()
|
||||
.slice(0, MAX_COMMENT_LENGTH)
|
||||
if (message === '') return c.body(null, 400)
|
||||
|
||||
const comment = await createRoomComment(c.env.DB, roomId, playerId, {
|
||||
subRoomId,
|
||||
message,
|
||||
style: intOrNull(typeof body.style === 'string' ? body.style : undefined) ?? 0,
|
||||
positionX: floatOrZero(body.positionX),
|
||||
positionY: floatOrZero(body.positionY),
|
||||
positionZ: floatOrZero(body.positionZ),
|
||||
})
|
||||
if (comment === null) return c.body(null, 400)
|
||||
|
||||
return c.json(comment)
|
||||
}
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare roomcomments',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Room comments for recflare, a private-server reimplementation of the Rec Room',
|
||||
'backend — the notes a player pins in a room’s scene, each with a message, a bubble',
|
||||
'style and the point in the subroom it floats at.',
|
||||
'',
|
||||
'Reads are public — a comment is a fixture of the room, so no token is needed. Writing',
|
||||
'needs one, since the comment is signed with the caller’s account id.',
|
||||
'',
|
||||
'`Unread` is always true. Nothing marks a comment read, and it is read state rather',
|
||||
'than a per-viewer flag: the create response carries `Unread: true` for the author’s',
|
||||
'own brand-new comment too.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://roomcomments.recflare.net', description: 'Production' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,234 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||
|
||||
import { ROOM_COMMENT_SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
|
||||
for (const stmt of ROOM_COMMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Ids are the read cursor, so each test starts from a clean, predictable sequence.
|
||||
await env.DB.prepare('DELETE FROM room_comment').run()
|
||||
await env.DB.prepare(`DELETE FROM sqlite_sequence WHERE name = 'room_comment'`).run()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
|
||||
// into the JWT_SECRET store.
|
||||
const TEST_SECRET = 'test-signing-key'
|
||||
|
||||
function b64url(input: ArrayBuffer | string): string {
|
||||
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function bearer(sub = '205'): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub, exp: now + 3600 })
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(TEST_SECRET),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
|
||||
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
||||
}
|
||||
|
||||
/** Post a comment the way the client does — form-encoded, positions as float text. */
|
||||
async function postComment(
|
||||
roomId: number,
|
||||
fields: Record<string, string>,
|
||||
sub = '205'
|
||||
): Promise<Response> {
|
||||
return SELF.fetch(`${ORIGIN}/comments/create/${roomId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
}
|
||||
|
||||
it('answers the health check', async () => {
|
||||
const res = await SELF.fetch(ORIGIN)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ service: 'roomcomments', status: 'ok' })
|
||||
})
|
||||
|
||||
it('creates a comment and answers it as the client reads it', async () => {
|
||||
const res = await postComment(1162, {
|
||||
message: 'nice room',
|
||||
subRoomId: '1296',
|
||||
style: '0',
|
||||
positionX: '1.5',
|
||||
positionY: '0.0',
|
||||
positionZ: '-3.25',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const comment = (await res.json()) as Record<string, unknown>
|
||||
expect(comment).toEqual({
|
||||
CommentId: 1,
|
||||
RoomId: 1162,
|
||||
SubRoomId: 1296,
|
||||
AccountId: 205,
|
||||
CreatedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
|
||||
Message: 'nice room',
|
||||
Style: 0,
|
||||
// True even here, on the author's own brand-new comment — it is read state, and
|
||||
// nothing marks a comment read.
|
||||
Unread: true,
|
||||
PositionX: 1.5,
|
||||
PositionY: 0,
|
||||
PositionZ: -3.25,
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips a C# float’s text as a number', async () => {
|
||||
// The digits the client actually posts: a float's shortest round-trip form. They go into
|
||||
// a REAL column and have to come back out identical, and as numbers — a quoted float
|
||||
// fails the client's parser.
|
||||
const res = await postComment(1162, {
|
||||
message: 'over here',
|
||||
subRoomId: '1307',
|
||||
style: '0',
|
||||
positionX: '-0.4979804',
|
||||
positionY: '1.568297',
|
||||
positionZ: '-0.05002981',
|
||||
})
|
||||
const comment = (await res.json()) as Record<string, unknown>
|
||||
expect(comment.PositionX).toBe(-0.4979804)
|
||||
expect(comment.PositionY).toBe(1.568297)
|
||||
expect(comment.PositionZ).toBe(-0.05002981)
|
||||
})
|
||||
|
||||
it('serves a room’s comments newest first', async () => {
|
||||
await postComment(1162, { message: 'first', subRoomId: '1296' })
|
||||
await postComment(1162, { message: 'second', subRoomId: '1296' })
|
||||
// A different room's comments never bleed into this one.
|
||||
await postComment(99, { message: 'elsewhere', subRoomId: '1' })
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/comments/get/1162?count=100&minId=-1`)
|
||||
expect(res.status).toBe(200)
|
||||
const comments = (await res.json()) as Array<Record<string, unknown>>
|
||||
expect(comments.map((x) => x.Message)).toEqual(['second', 'first'])
|
||||
expect(comments.map((x) => x.CommentId)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('treats minId as an exclusive cursor', async () => {
|
||||
await postComment(1162, { message: 'first', subRoomId: '1296' })
|
||||
await postComment(1162, { message: 'second', subRoomId: '1296' })
|
||||
await postComment(1162, { message: 'third', subRoomId: '1296' })
|
||||
|
||||
// A client holding up to id 1 polls for what was written since — not id 1 again.
|
||||
const res = await SELF.fetch(`${ORIGIN}/comments/get/1162?count=100&minId=1`)
|
||||
const comments = (await res.json()) as Array<Record<string, unknown>>
|
||||
expect(comments.map((x) => x.CommentId)).toEqual([3, 2])
|
||||
})
|
||||
|
||||
it('caps the page at count, keeping the newest', async () => {
|
||||
for (const message of ['a', 'b', 'c']) {
|
||||
await postComment(1162, { message, subRoomId: '1296' })
|
||||
}
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/comments/get/1162?count=2&minId=-1`)
|
||||
const comments = (await res.json()) as Array<Record<string, unknown>>
|
||||
expect(comments.map((x) => x.Message)).toEqual(['c', 'b'])
|
||||
})
|
||||
|
||||
it('narrows to one subroom when asked', async () => {
|
||||
await postComment(1162, { message: 'in 1296', subRoomId: '1296' })
|
||||
await postComment(1162, { message: 'in 1307', subRoomId: '1307' })
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/comments/get/1162?subRoomId=1307`)
|
||||
const comments = (await res.json()) as Array<Record<string, unknown>>
|
||||
expect(comments.map((x) => x.Message)).toEqual(['in 1307'])
|
||||
})
|
||||
|
||||
it('marks every comment unread, the reader’s own included', async () => {
|
||||
await postComment(1162, { message: 'mine', subRoomId: '1296' }, '205')
|
||||
await postComment(1162, { message: 'theirs', subRoomId: '1296' }, '999')
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/comments/get/1162`, { headers: await bearer('205') })
|
||||
const comments = (await res.json()) as Array<Record<string, unknown>>
|
||||
expect(comments.map((x) => [x.Message, x.Unread])).toEqual([
|
||||
['theirs', true],
|
||||
['mine', true],
|
||||
])
|
||||
})
|
||||
|
||||
it('serves the read without a token at all', async () => {
|
||||
await postComment(1162, { message: 'mine', subRoomId: '1296' }, '205')
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/comments/get/1162`)
|
||||
expect(res.status).toBe(200)
|
||||
const comments = (await res.json()) as Array<Record<string, unknown>>
|
||||
expect(comments.map((x) => x.Unread)).toEqual([true])
|
||||
})
|
||||
|
||||
it('has no comments for an unknown room — not a 404', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/comments/get/424242?count=100&minId=-1`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses an unauthenticated create', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/comments/create/1162`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ message: 'hi', subRoomId: '1296' }).toString(),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.text()).toBe('')
|
||||
})
|
||||
|
||||
it('refuses a blank message or a missing subroom', async () => {
|
||||
expect((await postComment(1162, { message: ' ', subRoomId: '1296' })).status).toBe(400)
|
||||
expect((await postComment(1162, { message: 'hi' })).status).toBe(400)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/comments/get/1162`)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
it('defaults an unparseable style and position to 0 rather than NaN', async () => {
|
||||
const res = await postComment(1162, {
|
||||
message: 'garbled',
|
||||
subRoomId: '1296',
|
||||
style: 'x',
|
||||
positionX: 'NaN',
|
||||
})
|
||||
const comment = (await res.json()) as Record<string, unknown>
|
||||
expect(comment.Style).toBe(0)
|
||||
expect(comment.PositionX).toBe(0)
|
||||
expect(comment.PositionY).toBe(0)
|
||||
})
|
||||
|
||||
it('serves an openapi spec with no dangling refs', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as Record<string, unknown>
|
||||
expect(Object.keys(spec.paths as object).sort()).toEqual([
|
||||
'/',
|
||||
'/comments/create/{roomId}',
|
||||
'/comments/get/{roomId}',
|
||||
])
|
||||
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
|
||||
})
|
||||
Reference in New Issue
Block a user