[events] fixup to some event endpoints

This commit is contained in:
Devin Zuczek
2026-08-19 16:02:05 -04:00
parent bf4389f480
commit ec808d3cd8
4 changed files with 359 additions and 75 deletions
+68 -28
View File
@@ -17,11 +17,7 @@
* relational table rather than a JSON blob.
*/
import {
glyphLength,
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_NAME_LENGTH,
} from '@repo/domain'
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
@@ -172,23 +168,42 @@ interface EventRow {
}
/**
* The envelope the create/update writes answer with — the event nested under a status,
* rather than the bare record the read endpoints serve. `Result` is 0 on success.
*
* `TagModifyResult` is always null: the real API reports the outcome of the tag edit
* that rides along with the write, and we store no event tags (see the tag-filter
* chips, which are static). The field stays present because the client's parser
* expects it.
* The event as the `v2` envelope carries it: {@link PlayerEventBase} plus `Tags`, a plain
* array of tag NAMES. (The stored tags are `{ tag, type }` pairs, which is what the v1
* read's lowercase `tags` serves.) Defined on top of the base rather than beside it, so the
* feed and the envelope cannot drift apart on the fields they share.
*/
export interface PlayerEventResult {
Result: number
TagModifyResult: null
PlayerEvent: PlayerEvent
export interface PlayerEventEnvelope extends PlayerEventBase {
Tags: string[]
}
/** Wrap a stored event in the write envelope. */
export function toEventResult(event: PlayerEvent): PlayerEventResult {
return { Result: 0, TagModifyResult: null, PlayerEvent: event }
/**
* The envelope the `v2` routes answer with — the event nested under a status, rather than
* the bare record the `v1` reads serve. `Result` is 0 on success.
*
* `TagModifyResult` reports the tag edit that rides along with a write: its `Result` is 0
* and its `Tags` echo the tags the event now carries, which is what the client redraws its
* tag chips from. It is an OBJECT — it used to be served as null, back when no event tags
* were stored.
*/
export interface PlayerEventResult {
PlayerEvent: PlayerEventEnvelope
Result: number
TagModifyResult: { Result: number; Tags: string[] }
}
/**
* Wrap a stored event and its tags in the `v2` envelope. `tags` are the event's stored tag
* names — pass what `getEventTags` returns, so the answer reflects what was actually
* written rather than what was asked for.
*/
export function toEventResult(event: PlayerEvent, tags: EventTag[] = []): PlayerEventResult {
const names = tags.map((t) => t.tag)
return {
PlayerEvent: { Tags: names, ...toEventBase(event) },
Result: 0,
TagModifyResult: { Result: 0, Tags: names },
}
}
/**
@@ -226,24 +241,28 @@ export interface PlayerEventNotification {
}
/**
* The projection the browse feed (`GET /api/playerevents/v1`) serves. PascalCase like
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`)
* serves, and the same thing the v2 envelope carries once `Tags` is added. PascalCase like
* the stored record, but not identical to it — don't unify them:
*
* - it drops `State`, which the feed does not carry;
* - it drops `State`, which neither the feed nor the envelope carries;
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
* broadcasts an event yet, so it is always null).
* broadcasts an event yet, so it is always null);
* - its `ImageName` is a string: an event with no image reads `""`, where the record holds
* null.
*
* That's the shape observed on this endpoint; the by-id / bulk / search reads serve the
* stored record verbatim and keep `State`.
* The by-id / bulk / search reads serve the stored RECORD verbatim instead, `State` and
* nullable `ImageName` included. Two shapes; keep them apart.
*/
export interface PlayerEventListing extends Omit<PlayerEvent, 'State'> {
export interface PlayerEventBase extends Omit<PlayerEvent, 'State' | 'ImageName'> {
ImageName: string
BroadcastingRoomInstanceId: number | null
}
/** Project a stored event into the browse feed's listing. */
export function toEventListing(event: PlayerEvent): PlayerEventListing {
/** Project a stored event into the base shape the feed serves and the envelope wraps. */
export function toEventBase(event: PlayerEvent): PlayerEventBase {
const { State: _State, ...rest } = event
return { ...rest, BroadcastingRoomInstanceId: null }
return { ...rest, ImageName: event.ImageName ?? '', BroadcastingRoomInstanceId: null }
}
/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
@@ -707,6 +726,27 @@ export async function updateEvent(
return updated
}
/**
* Delete an event and everything hanging off it — its RSVPs (`event_attendee`) and its tags
* (`event_tag`) — in one batch, so a cancelled event can't leave rows behind that the
* attendee counts and the `#tag` search would still find. Event ids are assigned in
* sequence and never reused, but orphan rows would still be counted against whatever id
* they name.
*
* Answers the event as it was, so the caller can report what it deleted; `null` when there
* was no such event.
*/
export async function deleteEvent(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
await db.batch([
db.prepare('DELETE FROM event_attendee WHERE event_id = ?1').bind(eventId),
db.prepare('DELETE FROM event_tag WHERE event_id = ?1').bind(eventId),
db.prepare('DELETE FROM event WHERE id = ?1').bind(eventId),
])
return event
}
/** One event by id, or null when there's no such row. */
export async function getEventById(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
const row = await db
+32 -11
View File
@@ -610,29 +610,50 @@ export const PlayerEventDetailsDto = PlayerEventDto.extend({
tags: z
.array(z.object({ tag: z.string(), type: z.int() }))
.optional()
.describe('Present only with `includeDetails=True`, and always empty'),
.describe('Present only with `includeDetails=True`; the stored `{ tag, type }` pairs'),
})
/**
* `GET /api/playerevents/v1` — the browse feed's listing. The same record minus
* `State`, plus a `BroadcastingRoomInstanceId` (always null — nothing broadcasts an
* event yet). That's the shape observed on this endpoint; the other reads serve the
* stored record verbatim, so don't unify the two.
* The client's BASE event, 17 keys — what `GET /api/playerevents/v1` serves, and what the
* v2 envelope carries once `Tags` is added. The stored record minus `State`, with
* `ImageName` as a string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null —
* nothing broadcasts an event yet).
*
* The by-id, bulk and search reads serve the stored RECORD verbatim instead, so don't unify
* the two.
*/
export const PlayerEventListingDto = PlayerEventDto.omit({ State: true }).extend({
export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName: true }).extend({
ImageName: z.string().describe('Empty string when the event has no image, never null'),
BroadcastingRoomInstanceId: z
.int()
.nullable()
.describe('Always null — no event broadcasts to a room instance yet'),
})
/** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */
/**
* The event as the v2 envelope carries it: the stored record MINUS `State`, PLUS `Tags`
* (tag names, not the `{ tag, type }` pairs the v1 read's lowercase `tags` serves) and
* `BroadcastingRoomInstanceId`. `ImageName` is `""` rather than null when there is no image.
*/
export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({
Tags: z.array(z.string()).describe('The events tag names'),
})
/**
* The `{ PlayerEvent, Result, TagModifyResult }` envelope the v2 routes answer with — the
* writes and `GET /api/playerevents/v2/{eventId}`.
*
* `TagModifyResult` reports the tag edit that rides along with a write: `Result` 0 and the
* tags the event now carries. It is an object — it was served as null back when no event
* tags were stored.
*/
export const PlayerEventResultDto = z.object({
PlayerEvent: PlayerEventEnvelopeDto,
Result: z.int().describe('0 = success'),
TagModifyResult: z
.null()
.describe('Always null — the write carries no tag edit, as no event tags are stored'),
PlayerEvent: PlayerEventDto,
TagModifyResult: z.object({
Result: z.int().describe('0 = success'),
Tags: z.array(z.string()).describe('The tags the event now carries'),
}),
})
/**
+100 -12
View File
@@ -8,6 +8,7 @@ import { logger } from '@repo/hono-helpers'
import { NotificationType } from '../../../notify/src/notification-types'
import {
createEvent,
deleteEvent,
eventInputRejection,
getEventAttendees,
getEventById,
@@ -22,7 +23,7 @@ import {
parseEventBody,
searchEvents,
setEventResponse,
toEventListing,
toEventBase,
toEventNotification,
toEventResponse,
toEventResult,
@@ -36,10 +37,10 @@ import {
json,
jsonBody,
pageParams,
PlayerEventBaseDto,
PlayerEventBulkInviteRequest,
PlayerEventDetailsDto,
PlayerEventDto,
PlayerEventListingDto,
PlayerEventReportRequest,
PlayerEventRequest,
PlayerEventRespondRequest,
@@ -145,8 +146,9 @@ async function notifyInvited(
*/
export const eventRoutes = new Hono<App>({ strict: false })
// The player-events browse feed — everything upcoming or running, soonest first. Same
// query `/search` runs with no text, but its own projection: this feed drops `State`
// and carries a `BroadcastingRoomInstanceId`, so it goes through `toEventListing`.
// query `/search` runs with no text, but its own projection: the feed serves the client's
// BASE event (17 keys — no `State`, a string `ImageName`, plus `BroadcastingRoomInstanceId`),
// which is the v2 envelope's event minus `Tags`. Hence `toEventBase`.
.get(
'/api/playerevents/v1',
describeRoute({
@@ -156,18 +158,19 @@ export const eventRoutes = new Hono<App>({ strict: false })
'The default feed on the player-events screen: every event that has not finished ' +
'yet — upcoming and running — soonest first, paginated via skip/take. A bare ' +
'array.\n\n' +
'Each entry is the browse LISTING, not the stored record the by-id, bulk and ' +
'search reads serve: it drops `State` and carries ' +
'Each entry is the clients BASE event — the v2 envelopes event minus `Tags`, 17 ' +
'keys — not the stored record the by-id, bulk and search reads serve: it drops ' +
'`State`, serves `ImageName` as `""` rather than null, and carries ' +
'`BroadcastingRoomInstanceId` (always null — nothing broadcasts an event yet). ' +
'That is the shape observed on this endpoint; keep the two projections apart.',
parameters: pageParams(50),
responses: { 200: json(PlayerEventListingDto.array(), 'The events that have not ended') },
responses: { 200: json(PlayerEventBaseDto.array(), 'The events that have not ended') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
const events = await searchEvents(c.env.DB, '', skip, take)
return c.json(events.map(toEventListing))
return c.json(events.map(toEventBase))
}
)
@@ -399,7 +402,8 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
const updated = await setEventResponse(c.env.DB, eventId, id, type)
return updated === null ? c.body(null, 404) : c.json(toEventResult(updated))
if (updated === null) return c.body(null, 404)
return c.json(toEventResult(updated, await getEventTags(c.env.DB, eventId)))
}
)
@@ -530,7 +534,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
const result = await inviteToEvent(c.env.DB, eventId, invited)
// inviteToEvent only returns null when the row vanished, which the read above rules out.
await notifyInvited(c, result!.event, result!.added)
return c.json(toEventResult(result!.event))
return c.json(toEventResult(result!.event, await getEventTags(c.env.DB, eventId)))
}
)
@@ -573,7 +577,91 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (eventInputRejection(input) !== null) return c.body(null, 400)
const event = await createEvent(c.env.DB, id, input)
await notifyEventCreated(c, event, input.tags ?? [])
return c.json(toEventResult(event))
// Read the tags back rather than echoing what was posted: the envelope reports what
// the event now carries, which is what the client redraws its chips from.
return c.json(toEventResult(event, await getEventTags(c.env.DB, event.PlayerEventId)))
}
)
// Delete an event. Creator-only, and it takes the RSVPs and tags with it — a cancelled
// event that left its `event_attendee` rows behind would keep being counted, and its
// `event_tag` rows would keep answering `#tag` searches for an event nobody can open.
//
// Registered for POST and DELETE both: the path spells the verb itself (`/delete/{id}`),
// which is how the reference exposes it, and a client that reaches for the HTTP verb
// instead should not get a 404 for being right.
//
// Answers the v2 envelope carrying the event as it WAS, so the caller can report what it
// removed; an unknown event is 404, and someone else's is 403.
.on(
['POST', 'DELETE'],
'/api/playerevents/v2/delete/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Delete a player event',
description:
'Deletes an event the caller created, along with its RSVPs and its tags — an event ' +
'whose attendee rows outlived it would still be counted, and its tags would still ' +
'answer `#tag` searches.\n\n' +
'Creator only: anyone else gets 403, and an unknown event 404. Answers the v2 ' +
'envelope carrying the event as it was just before it went. Both POST and DELETE ' +
'reach it — the path names the verb, which is the form the client uses.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
responses: {
200: json(PlayerEventResultDto, 'The event that was deleted'),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const existing = await getEventById(c.env.DB, eventId)
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
// Read the tags before the delete takes them, so the envelope can still report what
// the event carried.
const tags = await getEventTags(c.env.DB, eventId)
const deleted = await deleteEvent(c.env.DB, eventId)
// deleteEvent only answers null when the row vanished, which the read above rules out.
return c.json(toEventResult(deleted!, tags))
}
)
// Read one event in the v2 envelope — the same `{ PlayerEvent, Result, TagModifyResult }`
// the writes answer, so a client that just created or edited an event and one that is
// opening it cold parse the same thing.
//
// The v1 read next to it stays the BARE record on purpose: it is a different shape for a
// different caller (no envelope, `State` present, tags only behind `includeDetails`).
// Two shapes of one event; don't unify them.
.get(
'/api/playerevents/v2/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'One player event (v2 envelope)',
description:
'A single event wrapped in the same `{ PlayerEvent, Result, TagModifyResult }` ' +
'envelope the v2 writes answer with — `Tags` inline, `BroadcastingRoomInstanceId` ' +
'present, no `State`. 404 when there is no such event.\n\n' +
'`TagModifyResult` carries the events tags here too, even though a read edits ' +
'nothing: the client reads its chips out of that field either way.',
parameters: [idParam('eventId', 'Event id')],
responses: {
200: json(PlayerEventResultDto, 'The event in the v2 envelope'),
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.body(null, 404)
return c.json(toEventResult(event, await getEventTags(c.env.DB, eventId)))
}
)
@@ -615,7 +703,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
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!))
return c.json(toEventResult(updated!, await getEventTags(c.env.DB, eventId)))
}
)
+159 -24
View File
@@ -48,7 +48,7 @@ import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../war
import type { SavedImage } from '@repo/domain'
import type { Env } from '../../context'
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
import type { PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
declare module 'cloudflare:test' {
@@ -3403,7 +3403,9 @@ describe('player events', () => {
body: JSON.stringify(body),
})
const create = async (body: unknown, sub = '42'): Promise<PlayerEvent> => {
// The write envelope's event, which is NOT the stored record: no `State`, plus `Tags`
// and `BroadcastingRoomInstanceId`. Tests that want the record read it back over v1.
const create = async (body: unknown, sub = '42'): Promise<PlayerEventEnvelope> => {
const res = await post('/api/playerevents/v2', body, sub)
expect(res.status).toBe(200)
return ((await res.json()) as PlayerEventResult).PlayerEvent
@@ -3412,12 +3414,25 @@ describe('player events', () => {
const get = async (path: string, sub?: string): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, sub ? { headers: await bearer(sub) } : undefined)
/**
* The stored RECORD behind an envelope's event — what the v1 reads serve. The envelope
* drops `State`, adds `Tags`/`BroadcastingRoomInstanceId`, and turns a null `ImageName`
* into `""`, so going back the other way undoes exactly those.
*/
const asRecord = (
event: PlayerEventEnvelope,
imageName: string | null = event.ImageName
): PlayerEvent => {
const { Tags: _tags, BroadcastingRoomInstanceId: _broadcast, ...rest } = event
return { ...rest, ImageName: imageName, State: 0 }
}
// The fixture set every test below reads. Times are relative to the run so the
// upcoming/live/finished distinction the browse queries make is real.
let upcoming: PlayerEvent
let clubEvent: PlayerEvent
let liveEvent: PlayerEvent
let pastEvent: PlayerEvent
let upcoming: PlayerEventEnvelope
let clubEvent: PlayerEventEnvelope
let liveEvent: PlayerEventEnvelope
let pastEvent: PlayerEventEnvelope
beforeAll(async () => {
// Posted nested under `PlayerEvent` — the envelope form the client sends back.
@@ -3502,8 +3517,11 @@ describe('player events', () => {
})
expect(res.status).toBe(401)
// The stored record carries exactly the client's field set — nothing more.
// The envelope's event: the client's field set, plus `Tags` and
// `BroadcastingRoomInstanceId`, and WITHOUT `State` — the bare record the v1 read
// serves is the one that carries that.
expect(upcoming).toEqual({
Tags: [],
PlayerEventId: upcoming.PlayerEventId,
CreatorPlayerId: 42,
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
@@ -3515,12 +3533,12 @@ describe('player events', () => {
StartTime: at(HOUR),
EndTime: at(2 * HOUR),
AttendeeCount: 1,
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: true,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
BroadcastingRoomInstanceId: null,
})
// Timestamps come back at seconds precision, as the client sends them.
expect(upcoming.StartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
@@ -3565,12 +3583,36 @@ describe('player events', () => {
})
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 res = await post('/api/playerevents/v2', {
Name: 'Enveloped',
RoomId: 3,
tags: [{ tag: 'music', type: 0 }],
})
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Always null: no event tags are stored, but the field has to be present.
expect(body.TagModifyResult).toBeNull()
expect(body.PlayerEvent.Name).toBe('Enveloped')
// The tags ride inline on the event AND in TagModifyResult, as NAMES — not the
// `{ tag, type }` pairs the v1 read's lowercase `tags` serves.
expect(body.PlayerEvent.Tags).toEqual(['music'])
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] })
// No `State`, and the broadcast instance is present and null.
expect(body.PlayerEvent).not.toHaveProperty('State')
expect(body.PlayerEvent.BroadcastingRoomInstanceId).toBeNull()
})
test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => {
const written = await post('/api/playerevents/v2', {
Name: 'ReadBack',
RoomId: 3,
tags: [{ tag: 'music', type: 0 }],
})
const created = (await written.json()) as PlayerEventResult
const res = await get(`/api/playerevents/v2/${created.PlayerEvent.PlayerEventId}`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual(created)
expect((await get('/api/playerevents/v2/9999999')).status).toBe(404)
})
test('POST /api/playerevents/v2 pushes a PlayerEventCreated notification to the creator', async () => {
@@ -3631,9 +3673,9 @@ describe('player events', () => {
RoomId: 0,
SubRoomId: null,
ClubId: null,
ImageName: null,
// The envelope's ImageName is a string: the record's null reads as "" here.
ImageName: '',
AttendeeCount: 1,
State: 0,
Accessibility: 1,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: false,
@@ -3647,27 +3689,32 @@ describe('player events', () => {
test('GET /api/playerevents/v1/:eventId serves the bare event', async () => {
const res = await get(`/api/playerevents/v1/${upcoming.PlayerEventId}`)
expect(res.status).toBe(200)
// No envelope here — unlike the writes.
expect(await res.json()).toEqual(upcoming)
// No envelope here — unlike the writes — and the bare RECORD, which carries `State`
// and neither `Tags` nor `BroadcastingRoomInstanceId`.
const body = await res.json()
expect(body).toEqual(asRecord(upcoming))
expect(Object.hasOwn(body as object, 'Tags')).toBe(false)
expect(Object.hasOwn(body as object, 'State')).toBe(true)
expect((await get('/api/playerevents/v1/999999')).status).toBe(404)
})
test('GET /api/playerevents/v1/:eventId?includeDetails=True adds only `tags`', async () => {
const path = `/api/playerevents/v1/${upcoming.PlayerEventId}`
// The flag's whole effect: the lowercase `tags`, empty (no event tags are stored).
// The flag's whole effect: the lowercase `tags` — the `{ tag, type }` pairs, not the
// envelope's names. This event carries none.
expect(await (await get(`${path}?includeDetails=True`)).json()).toEqual({
...upcoming,
...asRecord(upcoming),
tags: [],
})
// Accepted case-insensitively — the client sends `True`.
expect(await (await get(`${path}?includeDetails=true`)).json()).toEqual({
...upcoming,
...asRecord(upcoming),
tags: [],
})
// Anything else is the bare record, with no `tags` key at all.
expect(await (await get(`${path}?includeDetails=False`)).json()).toEqual(upcoming)
expect(await (await get(path)).json()).toEqual(upcoming)
expect(await (await get(`${path}?includeDetails=False`)).json()).toEqual(asRecord(upcoming))
expect(await (await get(path)).json()).toEqual(asRecord(upcoming))
})
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
@@ -3728,7 +3775,29 @@ describe('player events', () => {
// The listing projection — no `State`, and a null broadcasting instance — not the
// stored record the by-id read serves.
const entry = feed.find((e) => e.PlayerEventId === upcoming.PlayerEventId)!
expect(entry).toEqual({ ...upcoming, State: undefined, BroadcastingRoomInstanceId: null })
expect(entry).toEqual({
...asRecord(upcoming),
State: undefined,
BroadcastingRoomInstanceId: null,
})
// The base event, exactly: the v2 envelope's event minus `Tags`, 17 keys.
expect(Object.keys(entry).sort()).toEqual(
Object.keys(upcoming)
.filter((k) => k !== 'Tags')
.sort()
)
expect(Object.keys(entry)).toHaveLength(17)
// An event with no image serves `""` here, not the record's null.
const imageless = await create({ RoomId: 3, Name: 'No Banner', StartTime: at(HOUR) })
const withoutImage = (
(await (await get('/api/playerevents/v1?take=50')).json()) as Array<{
PlayerEventId: number
ImageName: string
}>
).find((e) => e.PlayerEventId === imageless.PlayerEventId)!
expect(withoutImage.ImageName).toBe('')
expect(Object.hasOwn(entry, 'State')).toBe(false)
// Paged like the other feeds.
@@ -4150,9 +4219,9 @@ describe('player events', () => {
// Only the name moved; a partial post can't blank out the rest.
expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' })
// And it stuck.
// And it stuck — read back as the bare record, which the envelope's event is not.
expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual(
body.PlayerEvent
asRecord(body.PlayerEvent, null)
)
})
@@ -4165,10 +4234,73 @@ describe('player events', () => {
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.ClubId).toBeNull()
expect(updated.ImageName).toBeNull()
// Cleared on the record, which the envelope reports as "" — its ImageName is a string.
expect(updated.ImageName).toBe('')
expect(
((await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()) as PlayerEvent)
.ImageName
).toBeNull()
expect(updated.SubRoomId).toBe(6)
})
test('POST /api/playerevents/v2/delete/:eventId removes the event, its RSVPs and its tags', async () => {
const event = await create({
RoomId: 3,
Name: 'Cancelled',
StartTime: at(HOUR),
Tags: [{ tag: 'meetup', type: 2 }],
})
const eventId = event.PlayerEventId
// Someone else RSVPs, so there is more than the creator's own row to clean up.
await post('/api/playerevents/v1/respond', { PlayerEventId: eventId, Type: 1 }, '43')
const rows = async (table: string) =>
(
await env.DB.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE event_id = ?1`)
.bind(eventId)
.first<{ n: number }>()
)?.n
expect(await rows('event_attendee')).toBe(2)
expect(await rows('event_tag')).toBe(1)
// Auth-gated, and creator-only.
expect(
(
await exports.default.fetch(`${ORIGIN}/api/playerevents/v2/delete/${eventId}`, {
method: 'POST',
})
).status
).toBe(401)
expect((await post(`/api/playerevents/v2/delete/${eventId}`, {}, '43')).status).toBe(403)
expect((await post('/api/playerevents/v2/delete/999999', {})).status).toBe(404)
const res = await post(`/api/playerevents/v2/delete/${eventId}`, {})
expect(res.status).toBe(200)
// The envelope carries the event as it was, tags included — the caller can report
// what it removed.
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
expect(body.PlayerEvent.PlayerEventId).toBe(eventId)
expect(body.PlayerEvent.Tags).toEqual(['meetup'])
// Gone, and nothing left hanging off it: orphan RSVPs would keep being counted and
// orphan tags would keep answering `#tag` searches.
expect((await get(`/api/playerevents/v1/${eventId}`)).status).toBe(404)
expect(await rows('event_attendee')).toBe(0)
expect(await rows('event_tag')).toBe(0)
})
test('DELETE /api/playerevents/v2/delete/:eventId works too', async () => {
// The path names the verb, but a client reaching for the HTTP one is right as well.
const event = await create({ RoomId: 3, Name: 'Also Cancelled', StartTime: at(HOUR) })
const res = await exports.default.fetch(
`${ORIGIN}/api/playerevents/v2/delete/${event.PlayerEventId}`,
{ method: 'DELETE', headers: await bearer('42') }
)
expect(res.status).toBe(200)
expect((await get(`/api/playerevents/v1/${event.PlayerEventId}`)).status).toBe(404)
})
test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => {
const event = await create({ RoomId: 5, Name: 'Fixed' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
@@ -4208,6 +4340,7 @@ describe('openapi', () => {
)
expect([...documented].sort()).toEqual([
'DELETE /api/images/v1/deletesaved',
'DELETE /api/playerevents/v2/delete/{eventId}',
'GET /api/PlayerReporting/v1/moderationBlockDetails',
'GET /api/PlayerReporting/v1/voteToKickReasons',
'GET /api/activities/charades/v1/words/{activity}',
@@ -4267,6 +4400,7 @@ describe('openapi', () => {
'GET /api/playerevents/v1/tagfilters',
'GET /api/playerevents/v1/{eventId}',
'GET /api/playerevents/v1/{eventId}/responses',
'GET /api/playerevents/v2/{eventId}',
'GET /api/players/v1/playerPhotoTaggingSetting',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
@@ -4320,6 +4454,7 @@ describe('openapi', () => {
'POST /api/playerevents/v1/report',
'POST /api/playerevents/v1/respond',
'POST /api/playerevents/v2',
'POST /api/playerevents/v2/delete/{eventId}',
'POST /api/playerevents/v2/{eventId}',
'POST /api/players/v1/progression/bulk',
'POST /api/players/v2/progression/bulk',