mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[events] fixup to some event endpoints
This commit is contained in:
+68
-28
@@ -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
@@ -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'),
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
+100
-12
@@ -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 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<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 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<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)))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user