mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
[event] fixup even listing, eliminate some misc log errors
This commit is contained in:
@@ -73,14 +73,49 @@ export function isEventResponseType(value: number): boolean {
|
|||||||
return EVENT_RESPONSE_VALUES.includes(value)
|
return EVENT_RESPONSE_VALUES.includes(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One player's answer to one event. */
|
/**
|
||||||
|
* One player's answer to one event.
|
||||||
|
*
|
||||||
|
* `id` is the row's SQLite `rowid` — the table has a composite primary key, so it's a
|
||||||
|
* rowid table and the implicit id is free. It's what the RSVP list serves as
|
||||||
|
* `PlayerEventResponseId`, and it's stable: a changed answer is an UPDATE through the
|
||||||
|
* composite key (same rowid), and nothing ever deletes an RSVP row.
|
||||||
|
*/
|
||||||
export interface EventAttendeeRow {
|
export interface EventAttendeeRow {
|
||||||
|
id: number
|
||||||
event_id: number
|
event_id: number
|
||||||
player_id: number
|
player_id: number
|
||||||
status: number
|
status: number
|
||||||
responded_at: string
|
responded_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One RSVP as `GET /api/playerevents/v1/:eventId/responses` serves it — the PascalCase
|
||||||
|
* projection of an `event_attendee` row.
|
||||||
|
*
|
||||||
|
* `CreatedAt` is the stored `responded_at`, so it's the time of the answer CURRENTLY
|
||||||
|
* recorded, not of the player's first one: changing your mind updates the row in place
|
||||||
|
* (one row per player per event), and the client shows the answer that stands.
|
||||||
|
*/
|
||||||
|
export interface PlayerEventResponse {
|
||||||
|
PlayerEventResponseId: number
|
||||||
|
PlayerEventId: number
|
||||||
|
PlayerId: number
|
||||||
|
CreatedAt: string
|
||||||
|
Type: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project an RSVP row into the response the RSVP list serves. */
|
||||||
|
export function toEventResponse(row: EventAttendeeRow): PlayerEventResponse {
|
||||||
|
return {
|
||||||
|
PlayerEventResponseId: row.id,
|
||||||
|
PlayerEventId: row.event_id,
|
||||||
|
PlayerId: row.player_id,
|
||||||
|
CreatedAt: row.responded_at,
|
||||||
|
Type: row.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and
|
* A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and
|
||||||
* the settings the event runs under. Served verbatim by every read endpoint.
|
* the settings the event runs under. Served verbatim by every read endpoint.
|
||||||
@@ -170,6 +205,27 @@ export interface PlayerEventNotification {
|
|||||||
broadcastingRoomInstanceId: number | null
|
broadcastingRoomInstanceId: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The projection the browse feed (`GET /api/playerevents/v1`) serves. PascalCase like
|
||||||
|
* the stored record, but not identical to it — don't unify them:
|
||||||
|
*
|
||||||
|
* - it drops `State`, which the feed does not carry;
|
||||||
|
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
|
||||||
|
* broadcasts an event yet, so it is always null).
|
||||||
|
*
|
||||||
|
* That's the shape observed on this endpoint; the by-id / bulk / search reads serve the
|
||||||
|
* stored record verbatim and keep `State`.
|
||||||
|
*/
|
||||||
|
export interface PlayerEventListing extends Omit<PlayerEvent, 'State'> {
|
||||||
|
BroadcastingRoomInstanceId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a stored event into the browse feed's listing. */
|
||||||
|
export function toEventListing(event: PlayerEvent): PlayerEventListing {
|
||||||
|
const { State: _State, ...rest } = event
|
||||||
|
return { ...rest, BroadcastingRoomInstanceId: null }
|
||||||
|
}
|
||||||
|
|
||||||
/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
|
/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
|
||||||
function toTickPrecision(iso: string): string {
|
function toTickPrecision(iso: string): string {
|
||||||
const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso)
|
const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso)
|
||||||
@@ -438,18 +494,26 @@ export async function getEventResponse(
|
|||||||
playerId: number
|
playerId: number
|
||||||
): Promise<EventAttendeeRow | null> {
|
): Promise<EventAttendeeRow | null> {
|
||||||
return db
|
return db
|
||||||
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
|
.prepare('SELECT rowid AS id, * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
|
||||||
.bind(eventId, playerId)
|
.bind(eventId, playerId)
|
||||||
.first<EventAttendeeRow>()
|
.first<EventAttendeeRow>()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everyone who answered an event, in the order they responded. Backs a future guest list. */
|
/**
|
||||||
|
* Everyone who answered an event, in the order they responded — the guest list behind
|
||||||
|
* `GET /api/playerevents/v1/:eventId/responses`. Ties on the timestamp (the creator's
|
||||||
|
* own Going row shares its second with a fast first RSVP) break on the player id, so
|
||||||
|
* the order is stable.
|
||||||
|
*/
|
||||||
export async function getEventAttendees(
|
export async function getEventAttendees(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
eventId: number
|
eventId: number
|
||||||
): Promise<EventAttendeeRow[]> {
|
): Promise<EventAttendeeRow[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 ORDER BY responded_at, player_id')
|
.prepare(
|
||||||
|
`SELECT rowid AS id, * FROM event_attendee
|
||||||
|
WHERE event_id = ?1 ORDER BY responded_at, player_id`
|
||||||
|
)
|
||||||
.bind(eventId)
|
.bind(eventId)
|
||||||
.all<EventAttendeeRow>()
|
.all<EventAttendeeRow>()
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -435,6 +435,15 @@ export const KeepsakeConfig = z.object({
|
|||||||
SocialXpBoostEnabled: z.boolean(),
|
SocialXpBoostEnabled: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/keepsakes/categories` — the keepsake catalog, as a counted result set
|
||||||
|
* rather than the bare list the stubs around it serve. Empty until a catalog exists.
|
||||||
|
*/
|
||||||
|
export const KeepsakeCategories = z.object({
|
||||||
|
Results: JsonArray.describe('The categories — empty, as no keepsake catalog is stored'),
|
||||||
|
TotalResults: z.int().describe('How many results `Results` carries'),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint
|
* A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint
|
||||||
* serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and
|
* serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and
|
||||||
@@ -460,6 +469,19 @@ export const PlayerEventDto = z.object({
|
|||||||
CanRequestBroadcastPermissions: z.int(),
|
CanRequestBroadcastPermissions: z.int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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.
|
||||||
|
*/
|
||||||
|
export const PlayerEventListingDto = PlayerEventDto.omit({ State: true }).extend({
|
||||||
|
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 `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */
|
||||||
export const PlayerEventResultDto = z.object({
|
export const PlayerEventResultDto = z.object({
|
||||||
Result: z.int().describe('0 = success'),
|
Result: z.int().describe('0 = success'),
|
||||||
@@ -484,6 +506,23 @@ export const PlayerEventRequest = PlayerEventDto.partial().extend({
|
|||||||
.describe('The event’s fields, if nested rather than posted at the top level'),
|
.describe('The event’s fields, if nested rather than posted at the top level'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as
|
||||||
|
* the guest list serves it.
|
||||||
|
*/
|
||||||
|
export const PlayerEventResponseDto = z.object({
|
||||||
|
PlayerEventResponseId: z.int().describe('Stable id of the RSVP row'),
|
||||||
|
PlayerEventId: z.int(),
|
||||||
|
PlayerId: z.int(),
|
||||||
|
CreatedAt: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'When the answer that stands was given — a changed answer updates the row, so this ' +
|
||||||
|
'moves with it rather than recording the player’s first response'
|
||||||
|
),
|
||||||
|
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
|
||||||
|
})
|
||||||
|
|
||||||
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
|
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
|
||||||
export const PlayerEventRespondRequest = z.object({
|
export const PlayerEventRespondRequest = z.object({
|
||||||
PlayerEventId: z.int(),
|
PlayerEventId: z.int(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { logger } from '@repo/hono-helpers'
|
|||||||
import { NotificationType } from '../../../notify/src/notification-types'
|
import { NotificationType } from '../../../notify/src/notification-types'
|
||||||
import {
|
import {
|
||||||
createEvent,
|
createEvent,
|
||||||
|
getEventAttendees,
|
||||||
getEventById,
|
getEventById,
|
||||||
getEventsByClubs,
|
getEventsByClubs,
|
||||||
getEventsByCreator,
|
getEventsByCreator,
|
||||||
@@ -18,6 +19,8 @@ import {
|
|||||||
parseEventBody,
|
parseEventBody,
|
||||||
searchEvents,
|
searchEvents,
|
||||||
setEventResponse,
|
setEventResponse,
|
||||||
|
toEventListing,
|
||||||
|
toEventResponse,
|
||||||
toEventNotification,
|
toEventNotification,
|
||||||
toEventResult,
|
toEventResult,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
@@ -31,8 +34,10 @@ import {
|
|||||||
jsonBody,
|
jsonBody,
|
||||||
pageParams,
|
pageParams,
|
||||||
PlayerEventDto,
|
PlayerEventDto,
|
||||||
|
PlayerEventListingDto,
|
||||||
PlayerEventRequest,
|
PlayerEventRequest,
|
||||||
PlayerEventRespondRequest,
|
PlayerEventRespondRequest,
|
||||||
|
PlayerEventResponseDto,
|
||||||
PlayerEventResultDto,
|
PlayerEventResultDto,
|
||||||
PlayerEventsAll,
|
PlayerEventsAll,
|
||||||
PlayerEventsPage,
|
PlayerEventsPage,
|
||||||
@@ -83,6 +88,33 @@ async function notifyEventCreated(c: Context<App>, event: PlayerEvent): Promise<
|
|||||||
* if they're unified.
|
* if they're unified.
|
||||||
*/
|
*/
|
||||||
export const eventRoutes = new Hono<App>({ strict: false })
|
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`.
|
||||||
|
.get(
|
||||||
|
'/api/playerevents/v1',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Events'],
|
||||||
|
summary: 'The player-events browse feed',
|
||||||
|
description:
|
||||||
|
'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 ' +
|
||||||
|
'`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') },
|
||||||
|
}),
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
.get(
|
.get(
|
||||||
'/api/playerevents/v1/all',
|
'/api/playerevents/v1/all',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -390,6 +422,29 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// An event's guest list — every RSVP row, whatever the answer.
|
||||||
|
.get(
|
||||||
|
'/api/playerevents/v1/:eventId{[0-9]+}/responses',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Events'],
|
||||||
|
summary: 'An event’s RSVPs',
|
||||||
|
description:
|
||||||
|
'Every answer given to an event, in the order they were given — declines and ' +
|
||||||
|
'maybes included, not just the Going rows `AttendeeCount` counts. One entry per ' +
|
||||||
|
'player: a player who changed their mind has one row carrying the answer that ' +
|
||||||
|
'stands, and `CreatedAt` moves with it.\n\n' +
|
||||||
|
'A bare array, and an unknown event is an empty one rather than a 404 — like the ' +
|
||||||
|
'other list reads here. An event always has at least its creator’s Going row.',
|
||||||
|
parameters: [idParam('eventId', 'Event id')],
|
||||||
|
responses: { 200: json(PlayerEventResponseDto.array(), 'The event’s RSVPs') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const eventId = Number.parseInt(c.req.param('eventId'), 10)
|
||||||
|
const attendees = await getEventAttendees(c.env.DB, eventId)
|
||||||
|
return c.json(attendees.map(toEventResponse))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// A single event. Registered last so the literal `/bulk` and `/search` paths above
|
// A single event. Registered last so the literal `/bulk` and `/search` paths above
|
||||||
// are matched first; the `[0-9]+` constraint keeps them apart regardless.
|
// are matched first; the `[0-9]+` constraint keeps them apart regardless.
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
JsonArray,
|
JsonArray,
|
||||||
jsonBody,
|
jsonBody,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
|
KeepsakeCategories,
|
||||||
KeepsakeConfig,
|
KeepsakeConfig,
|
||||||
SanitizeRequest,
|
SanitizeRequest,
|
||||||
stringParam,
|
stringParam,
|
||||||
@@ -97,15 +98,25 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
|||||||
}),
|
}),
|
||||||
(c) => c.body(null, 204)
|
(c) => c.body(null, 204)
|
||||||
)
|
)
|
||||||
|
// A counted result set, NOT the bare list the stubs around it serve: the client parses
|
||||||
|
// this one as an object and an array fails it outright — "expected:'{', actual:'[', at
|
||||||
|
// offset:0", logged as "Failed to get keepsake categories" — which takes the keepsake
|
||||||
|
// load down with it. `TotalResults` is the length of `Results`, not a total behind a
|
||||||
|
// page; the reference returns `results.Length`.
|
||||||
.get(
|
.get(
|
||||||
'/api/keepsakes/categories',
|
'/api/keepsakes/categories',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Gameplay'],
|
tags: ['Gameplay'],
|
||||||
summary: 'Keepsake categories',
|
summary: 'Keepsake categories',
|
||||||
description: 'No keepsake catalog yet, so this is an empty list.',
|
description:
|
||||||
responses: { 200: json(JsonArray, 'An empty list') },
|
'No keepsake catalog yet, so the result set is empty — but it IS a result set ' +
|
||||||
|
'(`{ Results, TotalResults }`), not the empty list the stubs around it serve. ' +
|
||||||
|
'The client parses this one as an object and fails on an array ("expected \'{\', ' +
|
||||||
|
'actual \'[\'"), taking the keepsake load down with it. `TotalResults` counts ' +
|
||||||
|
'`Results` itself — there is no paging here.',
|
||||||
|
responses: { 200: json(KeepsakeCategories, 'An empty result set') },
|
||||||
}),
|
}),
|
||||||
(c) => c.json([])
|
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- Objectives / events / rewards ---------------------------------------
|
// ---- Objectives / events / rewards ---------------------------------------
|
||||||
|
|||||||
@@ -457,12 +457,14 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toMatchObject({ KeepsakeFeatureEnabled: true })
|
expect(await res.json()).toMatchObject({ KeepsakeFeatureEnabled: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/keepsakes/rooms/:id returns 204; categories returns []', async () => {
|
test('GET /api/keepsakes/rooms/:id returns 204; categories returns an empty result set', async () => {
|
||||||
const room = await exports.default.fetch(`${ORIGIN}/api/keepsakes/rooms/1`)
|
const room = await exports.default.fetch(`${ORIGIN}/api/keepsakes/rooms/1`)
|
||||||
expect(room.status).toBe(204)
|
expect(room.status).toBe(204)
|
||||||
|
// A result set, not a list: the client parses this one as an object and an array
|
||||||
|
// fails it outright ("expected '{', actual '['").
|
||||||
const cats = await exports.default.fetch(`${ORIGIN}/api/keepsakes/categories`)
|
const cats = await exports.default.fetch(`${ORIGIN}/api/keepsakes/categories`)
|
||||||
expect(cats.status).toBe(200)
|
expect(cats.status).toBe(200)
|
||||||
expect(await cats.json()).toEqual([])
|
expect(await cats.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /voice/config returns an object', async () => {
|
test('GET /voice/config returns an object', async () => {
|
||||||
@@ -3056,6 +3058,30 @@ describe('player events', () => {
|
|||||||
expect(await search('?skip=1&take=1')).toEqual([all[1]])
|
expect(await search('?skip=1&take=1')).toEqual([all[1]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/playerevents/v1 serves the browse feed as listings', async () => {
|
||||||
|
const res = await get('/api/playerevents/v1')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const feed = (await res.json()) as Array<PlayerEvent & { BroadcastingRoomInstanceId: null }>
|
||||||
|
|
||||||
|
// Upcoming and live, soonest first; what has already ended is left out.
|
||||||
|
const ids = feed.map((e) => e.PlayerEventId)
|
||||||
|
expect(ids).toContain(upcoming.PlayerEventId)
|
||||||
|
expect(ids).toContain(liveEvent.PlayerEventId)
|
||||||
|
expect(ids).not.toContain(pastEvent.PlayerEventId)
|
||||||
|
const starts = feed.map((e) => e.StartTime)
|
||||||
|
expect([...starts].sort()).toEqual(starts)
|
||||||
|
|
||||||
|
// 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(Object.hasOwn(entry, 'State')).toBe(false)
|
||||||
|
|
||||||
|
// Paged like the other feeds.
|
||||||
|
expect(await (await get('/api/playerevents/v1?take=1')).json()).toEqual([feed[0]])
|
||||||
|
expect(await (await get('/api/playerevents/v1?skip=1&take=1')).json()).toEqual([feed[1]])
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/playerevents/v1/searchlive serves what is running right now', async () => {
|
test('GET /api/playerevents/v1/searchlive serves what is running right now', async () => {
|
||||||
const res = await get('/api/playerevents/v1/searchlive')
|
const res = await get('/api/playerevents/v1/searchlive')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -3149,6 +3175,53 @@ describe('player events', () => {
|
|||||||
expect(fetched.AttendeeCount).toBe(1)
|
expect(fetched.AttendeeCount).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/playerevents/v1/:eventId/responses lists every RSVP, one per player', async () => {
|
||||||
|
const event = await create({ RoomId: 3, Name: 'Guest List', StartTime: at(HOUR) })
|
||||||
|
const id = event.PlayerEventId
|
||||||
|
const responses = async (): Promise<
|
||||||
|
Array<{
|
||||||
|
PlayerEventResponseId: number
|
||||||
|
PlayerEventId: number
|
||||||
|
PlayerId: number
|
||||||
|
CreatedAt: string
|
||||||
|
Type: number
|
||||||
|
}>
|
||||||
|
> => (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as never
|
||||||
|
|
||||||
|
// The creator's own Going row, from create.
|
||||||
|
const initial = await responses()
|
||||||
|
expect(initial).toEqual([
|
||||||
|
{
|
||||||
|
PlayerEventResponseId: expect.any(Number),
|
||||||
|
PlayerEventId: id,
|
||||||
|
PlayerId: 42,
|
||||||
|
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
|
||||||
|
Type: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// Declines and maybes are listed too — not just what AttendeeCount counts.
|
||||||
|
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '43')
|
||||||
|
const withDecline = await responses()
|
||||||
|
expect(withDecline.map((r) => [r.PlayerId, r.Type])).toEqual([
|
||||||
|
[42, 0],
|
||||||
|
[43, 2],
|
||||||
|
])
|
||||||
|
|
||||||
|
// Changing an answer updates the row in place: same id, new Type — never a second
|
||||||
|
// entry for the player.
|
||||||
|
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
|
||||||
|
const changed = await responses()
|
||||||
|
expect(changed).toHaveLength(2)
|
||||||
|
expect(changed[1]!.PlayerEventResponseId).toBe(withDecline[1]!.PlayerEventResponseId)
|
||||||
|
expect(changed[1]!.Type).toBe(1)
|
||||||
|
|
||||||
|
// An unknown event is an empty list, not a 404 — like the other list reads.
|
||||||
|
const unknown = await get('/api/playerevents/v1/999999/responses')
|
||||||
|
expect(unknown.status).toBe(200)
|
||||||
|
expect(await unknown.json()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => {
|
test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => {
|
||||||
const event = await create({ RoomId: 3, Name: 'Guarded' })
|
const event = await create({ RoomId: 3, Name: 'Guarded' })
|
||||||
|
|
||||||
@@ -3304,6 +3377,7 @@ describe('openapi', () => {
|
|||||||
'GET /api/messages/v2/get',
|
'GET /api/messages/v2/get',
|
||||||
'GET /api/playerReputation/v1/{id}',
|
'GET /api/playerReputation/v1/{id}',
|
||||||
'GET /api/playerReputation/v2/bulk',
|
'GET /api/playerReputation/v2/bulk',
|
||||||
|
'GET /api/playerevents/v1',
|
||||||
'GET /api/playerevents/v1/all',
|
'GET /api/playerevents/v1/all',
|
||||||
'GET /api/playerevents/v1/bulk',
|
'GET /api/playerevents/v1/bulk',
|
||||||
'GET /api/playerevents/v1/club/{clubId}',
|
'GET /api/playerevents/v1/club/{clubId}',
|
||||||
@@ -3312,6 +3386,7 @@ describe('openapi', () => {
|
|||||||
'GET /api/playerevents/v1/searchlive',
|
'GET /api/playerevents/v1/searchlive',
|
||||||
'GET /api/playerevents/v1/tagfilters',
|
'GET /api/playerevents/v1/tagfilters',
|
||||||
'GET /api/playerevents/v1/{eventId}',
|
'GET /api/playerevents/v1/{eventId}',
|
||||||
|
'GET /api/playerevents/v1/{eventId}/responses',
|
||||||
'GET /api/players/v1/progression/{id}',
|
'GET /api/players/v1/progression/{id}',
|
||||||
'GET /api/players/v2/progression/bulk',
|
'GET /api/players/v2/progression/bulk',
|
||||||
'GET /api/quickPlay/v1/getandclear',
|
'GET /api/quickPlay/v1/getandclear',
|
||||||
|
|||||||
@@ -33,19 +33,19 @@
|
|||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
"Key": "AntiHile.DC",
|
"Key": "AntiHile.DC",
|
||||||
"StartTime": null,
|
"StartTime": null,
|
||||||
"Value": "true"
|
"Value": "false"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
"Key": "AntiHile.LPD",
|
"Key": "AntiHile.LPD",
|
||||||
"StartTime": null,
|
"StartTime": null,
|
||||||
"Value": "true"
|
"Value": "false"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
"Key": "AntiHile.QD",
|
"Key": "AntiHile.QD",
|
||||||
"StartTime": null,
|
"StartTime": null,
|
||||||
"Value": "true"
|
"Value": "false"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
"Key": "Backtrace.stopTimeUTC",
|
"Key": "Backtrace.stopTimeUTC",
|
||||||
"StartTime": null,
|
"StartTime": null,
|
||||||
"Value": "9999-09-28 23:55"
|
"Value": "2026-06-01 00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"EndTime": null,
|
"EndTime": null,
|
||||||
|
|||||||
Reference in New Issue
Block a user