From c2da5fbab61d3dad53be0a38bb6e522335005545 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 19 Aug 2026 16:02:05 -0400 Subject: [PATCH] [events] fixup to some event endpoints --- apps/api/src/events-db.ts | 96 ++++++++---- apps/api/src/openapi.ts | 43 +++-- apps/api/src/routes/events.ts | 112 +++++++++++-- apps/api/src/test/integration/api.test.ts | 183 +++++++++++++++++++--- 4 files changed, 359 insertions(+), 75 deletions(-) diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index 9039db9..ad0ae26 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -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 { +export interface PlayerEventBase extends Omit { + 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 { + 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 { const row = await db diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 4a640d7..ef3cc41 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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 event’s 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'), + }), }) /** diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 008dfb5..3cfe322 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -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({ 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({ 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 client’s BASE event — the v2 envelope’s 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({ 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({ 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({ 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 event’s 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 event’s 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({ 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))) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 0124fad..3fac1e2 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 => { + // 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 => { 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 => 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',