mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
validation in some areas, maybe move this to schema later
This commit is contained in:
@@ -17,6 +17,12 @@
|
||||
* relational table rather than a JSON blob.
|
||||
*/
|
||||
|
||||
import {
|
||||
glyphLength,
|
||||
MAX_EVENT_DESCRIPTION_LENGTH,
|
||||
MAX_EVENT_NAME_LENGTH,
|
||||
} from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
|
||||
* seed rows).
|
||||
@@ -251,6 +257,30 @@ function asInt(value: unknown): number | undefined {
|
||||
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
|
||||
* rather than stored.
|
||||
*/
|
||||
/**
|
||||
* Why a parsed event body can't be stored, or `null` when it's fine.
|
||||
*
|
||||
* Length only. An event name is a title, not an identifier — "Building a Better Room
|
||||
* Using Trigonometry" is a real one — so the alphanumeric rule the account and room
|
||||
* names carry would be wrong here. Absent fields are skipped: an update posts only what
|
||||
* it changes, and create defaults a missing name rather than refusing it.
|
||||
*
|
||||
* The name is measured AFTER trimming, matching what create/update actually store.
|
||||
*/
|
||||
export function eventInputRejection(input: EventInput): string | null {
|
||||
const name = input.name?.trim()
|
||||
if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
|
||||
return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
|
||||
}
|
||||
if (
|
||||
input.description !== undefined &&
|
||||
glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH
|
||||
) {
|
||||
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseEventBody(body: unknown): EventInput {
|
||||
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
|
||||
const nested = outer.PlayerEvent
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getEventsByIds,
|
||||
getLiveEvents,
|
||||
isEventResponseType,
|
||||
eventInputRejection,
|
||||
parseEventBody,
|
||||
searchEvents,
|
||||
setEventResponse,
|
||||
@@ -327,6 +328,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The created event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -334,7 +336,13 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const event = await createEvent(c.env.DB, id, parseEventBody(body))
|
||||
const input = parseEventBody(body)
|
||||
// The one thing this route isn't lenient about. Everything else here defaults a
|
||||
// missing or unusable field, but a name or description past the stored length
|
||||
// can't be defaulted into something sensible — and truncating a player's event
|
||||
// description silently is worse than refusing it.
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const event = await createEvent(c.env.DB, id, input)
|
||||
await notifyEventCreated(c, event)
|
||||
return c.json(toEventResult(event))
|
||||
}
|
||||
@@ -359,6 +367,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The updated event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the event’s creator (empty body)' },
|
||||
404: { description: 'No such event (empty body)' },
|
||||
@@ -373,7 +382,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
|
||||
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const updated = await updateEvent(c.env.DB, eventId, parseEventBody(body))
|
||||
const input = parseEventBody(body)
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const updated = await updateEvent(c.env.DB, eventId, input)
|
||||
// updateEvent only returns null when the row vanished, which the read above rules out.
|
||||
return c.json(toEventResult(updated!))
|
||||
}
|
||||
|
||||
@@ -2420,6 +2420,44 @@ describe('player events', () => {
|
||||
expect(upcoming.StartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||
})
|
||||
|
||||
// The one thing the event writes are strict about. Everything else here defaults a
|
||||
// missing or unusable field (a nameless event becomes "Untitled Event"), but a name or
|
||||
// description past the stored length can't be defaulted into anything sensible, and
|
||||
// truncating a player's description silently is worse than refusing the write.
|
||||
//
|
||||
// Deliberately length ONLY: an event name is a title, not an identifier — the fixture
|
||||
// above is called "Building a Better Room Using Trigonometry" — so the alphanumeric
|
||||
// rule that guards usernames and room names would be wrong here.
|
||||
test('POST /api/playerevents/v2 caps the name at 64 and the description at 512', async () => {
|
||||
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(65), RoomId: 3 })).status).toBe(
|
||||
400
|
||||
)
|
||||
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(64), RoomId: 3 })).status).toBe(
|
||||
200
|
||||
)
|
||||
|
||||
const withDescription = (description: string) =>
|
||||
post('/api/playerevents/v2', { Name: 'Described', RoomId: 3, Description: description })
|
||||
expect((await withDescription('d'.repeat(513))).status).toBe(400)
|
||||
expect((await withDescription('d'.repeat(512))).status).toBe(200)
|
||||
// Counted in code points, so an emoji costs one character rather than two.
|
||||
expect((await withDescription('🎉'.repeat(512))).status).toBe(200)
|
||||
|
||||
// Spaces and punctuation stay fine — this is a title, not an identifier.
|
||||
expect(
|
||||
(await post('/api/playerevents/v2', { Name: "Bob's Big Night (2)!", RoomId: 3 })).status
|
||||
).toBe(200)
|
||||
|
||||
// The update path enforces the same limits, and a refusal leaves the event alone.
|
||||
const event = await create({ Name: 'EditMe', RoomId: 3 })
|
||||
const tooLong = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
Name: 'n'.repeat(65),
|
||||
})
|
||||
expect(tooLong.status).toBe(400)
|
||||
const after = await get(`/api/playerevents/v1/${event.PlayerEventId}`)
|
||||
expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe')
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => {
|
||||
const res = await post('/api/playerevents/v2', { Name: 'Enveloped', RoomId: 3 })
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
|
||||
Reference in New Issue
Block a user