diff --git a/lib/game/events.test.ts b/lib/game/events.test.ts new file mode 100644 index 0000000..b2fbb68 --- /dev/null +++ b/lib/game/events.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { openGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getWorldState } from './save' +import { DEFAULT_LIMIT, EventError, MAX_LIMIT, getEvents, parseEventLimit } from './events' + +const dirs: string[] = [] + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-events-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + return { db: await openGameDb(file), file } +} + +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +// Appends `count` synthetic events (turns 1..count) on top of the turn-0 new_game event. +async function seedEvents(db: GameDb, count: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + for (let i = 1; i <= count; i++) { + await db.db.insert(schema.events).values({ saveId: save.id, turn: i, type: 'test', message: `event ${i}`, createdAt: new Date(Date.UTC(2026, 8, 29, 0, i)).toISOString() }) + } +} + +describe('parseEventLimit', () => { + it('defaults to DEFAULT_LIMIT when the param is absent', () => { + expect(parseEventLimit(null)).toBe(DEFAULT_LIMIT) + }) + + it('accepts integers in [1, MAX_LIMIT]', () => { + expect(parseEventLimit('1')).toBe(1) + expect(parseEventLimit(String(MAX_LIMIT))).toBe(MAX_LIMIT) + expect(parseEventLimit('25')).toBe(25) + }) + + it('rejects non-integers and out-of-range values with a 400', () => { + for (const bad of ['abc', '', '0', '-3', String(MAX_LIMIT + 1), '2.5']) { + try { + parseEventLimit(bad) + expect.unreachable(`limit ${JSON.stringify(bad)} should have been rejected`) + } catch (err) { + expect(err).toBeInstanceOf(EventError) + expect((err as EventError).status).toBe(400) + } + } + }) +}) + +describe('getEvents', () => { + it('returns the latest events newest-first, sliced to limit', async () => { + const { db } = await freshFile() + await getWorldState(db) // auto-initializes the save (turn-0 new_game event) + await seedEvents(db, 5) + + const all = await getEvents(db, DEFAULT_LIMIT) + expect(all.map((e) => e.turn)).toEqual([5, 4, 3, 2, 1, 0]) // newest first, turn-0 line included + expect(all[all.length - 1].type).toBe('new_game') + + const two = await getEvents(db, 2) + expect(two.map((e) => [e.turn, e.message])).toEqual([[5, 'event 5'], [4, 'event 4']]) + }) +}) diff --git a/lib/game/events.ts b/lib/game/events.ts new file mode 100644 index 0000000..3ecdb11 --- /dev/null +++ b/lib/game/events.ts @@ -0,0 +1,40 @@ +import { desc } from 'drizzle-orm' +import * as schema from '@/lib/db/schema' +import type { GameDb } from '@/lib/db/client' + +export interface EventDto { + id: number + turn: number + type: string + message: string + createdAt: string +} + +export const DEFAULT_LIMIT = 50 +export const MAX_LIMIT = 200 + +export class EventError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + } +} + +// Parses the optional ?limit= query param. Absent → default; anything that is not an integer in +// [1, MAX_LIMIT] is a 400 (the route maps EventError to its status). +export function parseEventLimit(raw: string | null): number { + if (raw === null) return DEFAULT_LIMIT + const limit = Number(raw) + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { + throw new EventError(400, `limit must be an integer between 1 and ${MAX_LIMIT}`) + } + return limit +} + +// The latest N events of the save, newest first. Assumes a save exists (the route calls ensureSave). +export async function getEvents(db: GameDb, limit: number): Promise { + const rows = await db.db.select().from(schema.events).orderBy(desc(schema.events.id)).limit(limit) + return rows.map((e) => ({ id: e.id, turn: e.turn, type: e.type, message: e.message, createdAt: e.createdAt })) +}