diff --git a/services/mana-events/package.json b/services/mana-events/package.json index 36ff39a07..7b6758020 100644 --- a/services/mana-events/package.json +++ b/services/mana-events/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", + "test": "bun test", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio" diff --git a/services/mana-events/src/__tests__/cleanup.test.ts b/services/mana-events/src/__tests__/cleanup.test.ts new file mode 100644 index 000000000..09d10916e --- /dev/null +++ b/services/mana-events/src/__tests__/cleanup.test.ts @@ -0,0 +1,76 @@ +/** + * Rate-bucket sweeper unit test. + * + * Inserts a couple of historical buckets and one fresh bucket, runs + * sweepRateBuckets, and asserts only the historical ones disappear. + * + * Bucket rows have a FK to events_published.token, so we seed a + * snapshot first to keep the FK happy. + */ + +import { describe, it, expect, beforeEach, afterAll } from 'bun:test'; +import { sql } from 'drizzle-orm'; +import { buildTestApp, TEST_USER_ID } from './helpers'; +import { sweepRateBuckets } from '../lib/cleanup'; +import { eventsPublished, rsvpRateBuckets } from '../db/schema/events'; + +const app = buildTestApp(); +const TOKEN = 'TEST_SWEEP_TOKEN'; + +beforeEach(async () => { + await app.wipe(); + await app.db.insert(eventsPublished).values({ + token: TOKEN, + eventId: '00000000-0000-0000-0000-00000000beef', + userId: TEST_USER_ID, + title: 'Sweeper fixture', + startAt: new Date(), + }); +}); + +afterAll(async () => { + await app.wipe(); +}); + +function bucketLabel(hoursAgo: number): string { + const d = new Date(Date.now() - hoursAgo * 60 * 60 * 1000); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}`; +} + +describe('sweepRateBuckets', () => { + it('removes buckets older than 2h and keeps recent ones', async () => { + await app.db.insert(rsvpRateBuckets).values([ + { token: TOKEN, hourBucket: bucketLabel(0), count: 1 }, // current hour — keep + { token: TOKEN, hourBucket: bucketLabel(1), count: 1 }, // 1h ago — keep + { token: TOKEN, hourBucket: bucketLabel(3), count: 1 }, // 3h ago — drop + { token: TOKEN, hourBucket: bucketLabel(24), count: 1 }, // 1d ago — drop + ]); + + const removed = await sweepRateBuckets(app.db); + expect(removed).toBe(2); + + const remaining = await app.db.execute<{ hour_bucket: string }>( + sql`SELECT hour_bucket FROM events.rsvp_rate_buckets WHERE token = ${TOKEN} ORDER BY hour_bucket` + ); + expect(remaining.length).toBe(2); + // The two surviving buckets should both be within the last 2h + expect(remaining.map((r) => r.hour_bucket)).toEqual([bucketLabel(1), bucketLabel(0)]); + }); + + it('returns 0 when there is nothing stale to remove', async () => { + await app.db.insert(rsvpRateBuckets).values({ + token: TOKEN, + hourBucket: bucketLabel(0), + count: 1, + }); + + const removed = await sweepRateBuckets(app.db); + expect(removed).toBe(0); + }); + + it('handles an empty table without throwing', async () => { + const removed = await sweepRateBuckets(app.db); + expect(removed).toBe(0); + }); +}); diff --git a/services/mana-events/src/__tests__/events.test.ts b/services/mana-events/src/__tests__/events.test.ts new file mode 100644 index 000000000..f78fe643c --- /dev/null +++ b/services/mana-events/src/__tests__/events.test.ts @@ -0,0 +1,303 @@ +/** + * Host (authenticated) events endpoint tests. + * + * Uses the X-Test-User mock auth from helpers.ts so we can switch user + * identities mid-test to assert ownership behaviour without spinning + * up a real mana-auth. + */ + +import { describe, it, expect, beforeEach, afterAll } from 'bun:test'; +import { sql } from 'drizzle-orm'; +import { buildTestApp, authedRequest, jsonBody, TEST_USER_ID, OTHER_USER_ID } from './helpers'; + +const app = buildTestApp(); + +const futureIso = (daysAhead: number) => + new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000).toISOString(); + +function publishPayload(eventId: string, overrides: Record = {}) { + return { + eventId, + title: 'Hosted Test', + description: 'desc', + location: 'Berlin', + startAt: futureIso(7), + endAt: futureIso(7), + allDay: false, + color: '#f43f5e', + ...overrides, + }; +} + +beforeEach(async () => { + await app.wipe(); +}); + +afterAll(async () => { + await app.wipe(); +}); + +// ─── POST /api/v1/events/publish ────────────────────────────────── + +describe('POST /api/v1/events/publish', () => { + it('rejects requests without auth with 401', async () => { + const res = await app.fetch( + new Request('http://test/api/v1/events/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: jsonBody(publishPayload('00000000-0000-0000-0000-00000000000a')), + }) + ); + expect(res.status).toBe(401); + }); + + it('creates a snapshot and returns a 24-char token on first publish', async () => { + const eventId = '00000000-0000-0000-0000-00000000000a'; + const res = await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + body: jsonBody(publishPayload(eventId)), + }) + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { token: string; isNew: boolean }; + expect(body.isNew).toBe(true); + expect(body.token).toBeString(); + expect(body.token.length).toBe(24); + + const rows = await app.db.execute<{ title: string; user_id: string }>( + sql`SELECT title, user_id FROM events.events_published WHERE event_id = ${eventId}` + ); + expect(rows.length).toBe(1); + expect(rows[0]?.title).toBe('Hosted Test'); + expect(rows[0]?.user_id).toBe(TEST_USER_ID); + }); + + it('reuses the existing token when the same event is republished', async () => { + const eventId = '00000000-0000-0000-0000-00000000000b'; + const first = await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + body: jsonBody(publishPayload(eventId, { title: 'V1' })), + }) + ); + const { token: token1 } = (await first.json()) as { token: string }; + + const second = await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + body: jsonBody(publishPayload(eventId, { title: 'V2' })), + }) + ); + const { token: token2, isNew } = (await second.json()) as { + token: string; + isNew: boolean; + }; + + expect(token2).toBe(token1); + expect(isNew).toBe(false); + + const rows = await app.db.execute<{ title: string }>( + sql`SELECT title FROM events.events_published WHERE event_id = ${eventId}` + ); + expect(rows[0]?.title).toBe('V2'); + }); + + it('rejects republishing another user’s event with 403', async () => { + const eventId = '00000000-0000-0000-0000-00000000000c'; + // User A publishes + await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + body: jsonBody(publishPayload(eventId)), + }) + ); + + // User B tries to republish the same event + const res = await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + user: OTHER_USER_ID, + body: jsonBody(publishPayload(eventId)), + }) + ); + expect(res.status).toBe(403); + }); + + it('rejects payloads missing required fields with 400', async () => { + const res = await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + body: jsonBody({ title: 'No event id' }), + }) + ); + expect(res.status).toBe(400); + }); +}); + +// ─── PUT /api/v1/events/:eventId/snapshot ────────────────────────── + +describe('PUT /api/v1/events/:eventId/snapshot', () => { + const eventId = '00000000-0000-0000-0000-00000000000d'; + + beforeEach(async () => { + await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + body: jsonBody(publishPayload(eventId, { title: 'Original' })), + }) + ); + }); + + it('updates a single field without touching the others', async () => { + const res = await app.fetch( + authedRequest(`http://test/api/v1/events/${eventId}/snapshot`, { + method: 'PUT', + body: jsonBody({ eventId, title: 'Renamed' }), + }) + ); + expect(res.status).toBe(200); + + const rows = await app.db.execute<{ title: string; location: string }>( + sql`SELECT title, location FROM events.events_published WHERE event_id = ${eventId}` + ); + expect(rows[0]?.title).toBe('Renamed'); + expect(rows[0]?.location).toBe('Berlin'); // unchanged + }); + + it('rejects updates from non-owners with 403', async () => { + const res = await app.fetch( + authedRequest(`http://test/api/v1/events/${eventId}/snapshot`, { + method: 'PUT', + user: OTHER_USER_ID, + body: jsonBody({ eventId, title: 'Hacked' }), + }) + ); + expect(res.status).toBe(403); + }); + + it('returns 404 when the event was never published', async () => { + const res = await app.fetch( + authedRequest('http://test/api/v1/events/00000000-0000-0000-0000-00000000ffff/snapshot', { + method: 'PUT', + body: jsonBody({ eventId: '00000000-0000-0000-0000-00000000ffff', title: 'X' }), + }) + ); + expect(res.status).toBe(404); + }); +}); + +// ─── DELETE /api/v1/events/:eventId ──────────────────────────────── + +describe('DELETE /api/v1/events/:eventId', () => { + const eventId = '00000000-0000-0000-0000-00000000000e'; + + beforeEach(async () => { + await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + body: jsonBody(publishPayload(eventId)), + }) + ); + }); + + it('deletes the snapshot and cascades to RSVPs + rate buckets', async () => { + const tokenRow = await app.db.execute<{ token: string }>( + sql`SELECT token FROM events.events_published WHERE event_id = ${eventId}` + ); + const token = tokenRow[0]!.token; + + // Manually seed a rate bucket and an RSVP for this token + await app.db.execute( + sql`INSERT INTO events.public_rsvps (token, name, status) VALUES (${token}, 'X', 'yes')` + ); + await app.db.execute( + sql`INSERT INTO events.rsvp_rate_buckets (token, hour_bucket, count) VALUES (${token}, '2026-04-07T12', 1)` + ); + + const res = await app.fetch( + authedRequest(`http://test/api/v1/events/${eventId}`, { method: 'DELETE' }) + ); + expect(res.status).toBe(200); + + const counts = await app.db.execute<{ + snapshots: number; + rsvps: number; + buckets: number; + }>(sql` + SELECT + (SELECT count(*)::int FROM events.events_published WHERE event_id = ${eventId}) AS snapshots, + (SELECT count(*)::int FROM events.public_rsvps WHERE token = ${token}) AS rsvps, + (SELECT count(*)::int FROM events.rsvp_rate_buckets WHERE token = ${token}) AS buckets + `); + expect(counts[0]).toEqual({ snapshots: 0, rsvps: 0, buckets: 0 }); + }); + + it('returns deleted:false when the event was never published (idempotent)', async () => { + const res = await app.fetch( + authedRequest('http://test/api/v1/events/00000000-0000-0000-0000-aaaaaaaaaaaa', { + method: 'DELETE', + }) + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { deleted: boolean }; + expect(body.deleted).toBe(false); + }); + + it('rejects deletes from non-owners with 403', async () => { + const res = await app.fetch( + authedRequest(`http://test/api/v1/events/${eventId}`, { + method: 'DELETE', + user: OTHER_USER_ID, + }) + ); + expect(res.status).toBe(403); + }); +}); + +// ─── GET /api/v1/events/:eventId/rsvps ───────────────────────────── + +describe('GET /api/v1/events/:eventId/rsvps', () => { + const eventId = '00000000-0000-0000-0000-00000000000f'; + + beforeEach(async () => { + await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + body: jsonBody(publishPayload(eventId)), + }) + ); + }); + + it('returns the host’s RSVPs as a list', async () => { + const tokenRow = await app.db.execute<{ token: string }>( + sql`SELECT token FROM events.events_published WHERE event_id = ${eventId}` + ); + const token = tokenRow[0]!.token; + await app.db.execute( + sql`INSERT INTO events.public_rsvps (token, name, status) VALUES (${token}, 'Anna', 'yes')` + ); + + const res = await app.fetch(authedRequest(`http://test/api/v1/events/${eventId}/rsvps`)); + expect(res.status).toBe(200); + const body = (await res.json()) as { rsvps: { name: string; status: string }[] }; + expect(body.rsvps.length).toBe(1); + expect(body.rsvps[0]?.name).toBe('Anna'); + }); + + it('rejects another user reading the host’s RSVPs with 403', async () => { + const res = await app.fetch( + authedRequest(`http://test/api/v1/events/${eventId}/rsvps`, { + user: OTHER_USER_ID, + }) + ); + expect(res.status).toBe(403); + }); + + it('returns 404 if the event was never published', async () => { + const res = await app.fetch( + authedRequest('http://test/api/v1/events/00000000-0000-0000-0000-bbbbbbbbbbbb/rsvps') + ); + expect(res.status).toBe(404); + }); +}); diff --git a/services/mana-events/src/__tests__/health.test.ts b/services/mana-events/src/__tests__/health.test.ts new file mode 100644 index 000000000..c789498f9 --- /dev/null +++ b/services/mana-events/src/__tests__/health.test.ts @@ -0,0 +1,19 @@ +/** + * Trivial sanity test — verifies the test app boots and the public + * health route responds without auth or DB. + */ + +import { describe, it, expect } from 'bun:test'; +import { buildTestApp, publicRequest } from './helpers'; + +describe('health', () => { + const app = buildTestApp(); + + it('responds with ok', async () => { + const res = await app.fetch(publicRequest('http://test/health')); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; service: string }; + expect(body.status).toBe('ok'); + expect(body.service).toBe('mana-events'); + }); +}); diff --git a/services/mana-events/src/__tests__/helpers.ts b/services/mana-events/src/__tests__/helpers.ts new file mode 100644 index 000000000..2461887d1 --- /dev/null +++ b/services/mana-events/src/__tests__/helpers.ts @@ -0,0 +1,100 @@ +/** + * Shared test helpers for mana-events. + * + * Each test suite gets its own Hono app built via createApp() with a + * fake auth middleware that injects whichever userId the test wants + * via an X-Test-User header. Avoids spinning up a real mana-auth + + * JWKS for unit tests. + */ + +import type { MiddlewareHandler } from 'hono'; +import { sql } from 'drizzle-orm'; +import { createApp } from '../app'; +import { getDb, type Database } from '../db/connection'; +import type { Config } from '../config'; +import type { AuthUser } from '../middleware/jwt-auth'; + +const TEST_DB_URL = + process.env.TEST_DATABASE_URL || + process.env.DATABASE_URL || + 'postgresql://mana:devpassword@localhost:5432/mana_platform'; + +export const TEST_USER_ID = '00000000-0000-0000-0000-00000000beef'; +export const OTHER_USER_ID = '00000000-0000-0000-0000-0000000ff1ce'; + +/** Test app + db handle that share lifetime. */ +export interface TestApp { + db: Database; + fetch: (req: Request) => Promise | Response; + wipe(): Promise; +} + +const TEST_CONFIG: Config = { + port: 0, + databaseUrl: TEST_DB_URL, + manaAuthUrl: 'http://localhost:0', + cors: { origins: ['*'] }, + rateLimit: { + // Tight cap so the rate-limit test can hit it without sending + // hundreds of requests. + rsvpPerTokenPerHour: 5, + rsvpMaxPerToken: 20, + }, +}; + +/** + * Auth mock — reads the user id from the X-Test-User header. If the + * header is missing the request is rejected with 401, mirroring the + * real jwtAuth behaviour. + */ +function mockAuth(): MiddlewareHandler { + return async (c, next) => { + const userId = c.req.header('X-Test-User'); + if (!userId) { + return c.json({ statusCode: 401, message: 'Missing test user' }, 401); + } + const user: AuthUser = { userId, email: `${userId}@test.local`, role: 'user' }; + c.set('user', user); + await next(); + }; +} + +/** Build a fresh test app + return helpers for it. */ +export function buildTestApp(overrides: Partial = {}): TestApp { + const config: Config = { ...TEST_CONFIG, ...overrides }; + const db = getDb(config.databaseUrl); + const app = createApp(db, config, mockAuth()); + + return { + db, + fetch: (req: Request) => app.fetch(req), + async wipe() { + // Cascade FK from events_published handles public_rsvps + rate buckets + await db.execute(sql`DELETE FROM events.events_published`); + }, + }; +} + +/** Convenience: authenticated request as TEST_USER_ID. */ +export function authedRequest(url: string, init: RequestInit & { user?: string } = {}): Request { + const headers = new Headers(init.headers); + headers.set('X-Test-User', init.user || TEST_USER_ID); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + return new Request(url, { ...init, headers }); +} + +/** Convenience: unauthenticated request (for /api/v1/rsvp/* and /health). */ +export function publicRequest(url: string, init: RequestInit = {}): Request { + const headers = new Headers(init.headers); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + return new Request(url, { ...init, headers }); +} + +/** Build a JSON body. */ +export function jsonBody(payload: unknown): string { + return JSON.stringify(payload); +} diff --git a/services/mana-events/src/__tests__/rsvp.test.ts b/services/mana-events/src/__tests__/rsvp.test.ts new file mode 100644 index 000000000..e1b179704 --- /dev/null +++ b/services/mana-events/src/__tests__/rsvp.test.ts @@ -0,0 +1,242 @@ +/** + * Public RSVP endpoint tests — exercises everything reachable without + * authentication: snapshot fetch, response submission, validation, + * upsert dedup, capacity cap and per-token rate limiting. + * + * Runs against a real Postgres so the integration with Drizzle and + * the FK cascade is exercised faithfully. + */ + +import { describe, it, expect, beforeEach, afterAll } from 'bun:test'; +import { sql } from 'drizzle-orm'; +import { buildTestApp, publicRequest, jsonBody, TEST_USER_ID } from './helpers'; +import { eventsPublished } from '../db/schema/events'; + +const TOKEN = 'TEST_RSVP_TOKEN_001'; + +const app = buildTestApp(); + +async function seedSnapshot(overrides: Partial = {}) { + await app.db.insert(eventsPublished).values({ + token: TOKEN, + eventId: '00000000-0000-0000-0000-00000000aaaa', + userId: TEST_USER_ID, + title: 'Test Party', + description: 'Bring snacks', + location: 'Café am See', + startAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + endAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000), + allDay: false, + color: '#f43f5e', + ...overrides, + }); +} + +async function postRsvp(payload: Record): Promise { + return app.fetch( + publicRequest(`http://test/api/v1/rsvp/${TOKEN}`, { + method: 'POST', + body: jsonBody(payload), + }) + ); +} + +beforeEach(async () => { + await app.wipe(); +}); + +afterAll(async () => { + await app.wipe(); +}); + +// ─── GET /rsvp/:token ────────────────────────────────────────────── + +describe('GET /api/v1/rsvp/:token', () => { + it('returns the snapshot + zero summary for a fresh event', async () => { + await seedSnapshot(); + const res = await app.fetch(publicRequest(`http://test/api/v1/rsvp/${TOKEN}`)); + expect(res.status).toBe(200); + const body = (await res.json()) as { + event: { title: string; location: string; capacity: number | null }; + summary: { yes: number; no: number; maybe: number; totalAttending: number }; + }; + expect(body.event.title).toBe('Test Party'); + expect(body.event.location).toBe('Café am See'); + expect(body.summary).toEqual({ yes: 0, no: 0, maybe: 0, totalAttending: 0 }); + }); + + it('returns 404 for an unknown token', async () => { + const res = await app.fetch(publicRequest('http://test/api/v1/rsvp/NOPE')); + expect(res.status).toBe(404); + }); + + it('does not leak host userId or individual rsvp identities', async () => { + await seedSnapshot(); + await postRsvp({ name: 'Alice', email: 'alice@x.test', status: 'yes' }); + const res = await app.fetch(publicRequest(`http://test/api/v1/rsvp/${TOKEN}`)); + const body = await res.json(); + const text = JSON.stringify(body); + expect(text).not.toContain('alice@x.test'); + expect(text).not.toContain(TEST_USER_ID); + }); + + it('flags a cancelled snapshot via the cancelled boolean', async () => { + await seedSnapshot({ isCancelled: true }); + const res = await app.fetch(publicRequest(`http://test/api/v1/rsvp/${TOKEN}`)); + const body = (await res.json()) as { cancelled?: boolean }; + expect(body.cancelled).toBe(true); + }); +}); + +// ─── POST /rsvp/:token — happy path + summary aggregation ───────── + +describe('POST /api/v1/rsvp/:token — submit', () => { + beforeEach(async () => { + await seedSnapshot(); + }); + + it('records a yes RSVP and shows it in the summary', async () => { + const post = await postRsvp({ name: 'Anna', status: 'yes', plusOnes: 2 }); + expect(post.status).toBe(200); + + const get = await app.fetch(publicRequest(`http://test/api/v1/rsvp/${TOKEN}`)); + const body = (await get.json()) as { summary: { yes: number; totalAttending: number } }; + expect(body.summary.yes).toBe(1); + expect(body.summary.totalAttending).toBe(3); // 1 + 2 plus-ones + }); + + it('aggregates yes/no/maybe correctly across multiple guests', async () => { + await postRsvp({ name: 'Anna', status: 'yes', plusOnes: 1 }); + await postRsvp({ name: 'Bob', status: 'no' }); + await postRsvp({ name: 'Carol', status: 'maybe' }); + await postRsvp({ name: 'Dan', status: 'yes' }); + + const res = await app.fetch(publicRequest(`http://test/api/v1/rsvp/${TOKEN}`)); + const body = (await res.json()) as { + summary: { yes: number; no: number; maybe: number; totalAttending: number }; + }; + expect(body.summary).toEqual({ + yes: 2, + no: 1, + maybe: 1, + totalAttending: 3, // 2 yes + 1 plus-one + }); + }); + + it('upserts when the same (name, email) submits twice', async () => { + await postRsvp({ name: 'Anna', email: 'anna@x.test', status: 'yes', plusOnes: 2 }); + await postRsvp({ name: 'Anna', email: 'anna@x.test', status: 'no', plusOnes: 0 }); + + const rows = await app.db.execute<{ status: string; plus_ones: number }>( + sql`SELECT status, plus_ones FROM events.public_rsvps WHERE token = ${TOKEN}` + ); + expect(rows.length).toBe(1); + expect(rows[0]?.status).toBe('no'); + expect(rows[0]?.plus_ones).toBe(0); + }); + + it('treats null vs missing email as the same person', async () => { + await postRsvp({ name: 'Klaus', status: 'yes' }); + await postRsvp({ name: 'Klaus', email: null, status: 'maybe' }); + + const rows = await app.db.execute<{ status: string }>( + sql`SELECT status FROM events.public_rsvps WHERE token = ${TOKEN} AND name = 'Klaus'` + ); + expect(rows.length).toBe(1); + expect(rows[0]?.status).toBe('maybe'); + }); + + it('treats different emails as different people', async () => { + await postRsvp({ name: 'Anna', email: 'a@x.test', status: 'yes' }); + await postRsvp({ name: 'Anna', email: 'b@x.test', status: 'no' }); + + const rows = await app.db.execute( + sql`SELECT * FROM events.public_rsvps WHERE token = ${TOKEN}` + ); + expect(rows.length).toBe(2); + }); +}); + +// ─── Validation ──────────────────────────────────────────────────── + +describe('POST /api/v1/rsvp/:token — validation', () => { + beforeEach(async () => { + await seedSnapshot(); + }); + + it('rejects a missing name with 400', async () => { + const res = await postRsvp({ status: 'yes' }); + expect(res.status).toBe(400); + }); + + it('rejects an invalid status enum with 400', async () => { + const res = await postRsvp({ name: 'X', status: 'definitely-coming' }); + expect(res.status).toBe(400); + }); + + it('rejects a malformed email with 400', async () => { + const res = await postRsvp({ name: 'X', email: 'not-an-email', status: 'yes' }); + expect(res.status).toBe(400); + }); + + it('rejects an unknown token with 404', async () => { + const res = await app.fetch( + publicRequest('http://test/api/v1/rsvp/UNKNOWN', { + method: 'POST', + body: jsonBody({ name: 'X', status: 'yes' }), + }) + ); + expect(res.status).toBe(404); + }); + + it('rejects RSVPs to a cancelled event with 400', async () => { + await app.db.execute( + sql`UPDATE events.events_published SET is_cancelled = true WHERE token = ${TOKEN}` + ); + const res = await postRsvp({ name: 'X', status: 'yes' }); + expect(res.status).toBe(400); + }); + + it('rejects plus-ones outside [0, 20] with 400', async () => { + const tooMany = await postRsvp({ name: 'X', status: 'yes', plusOnes: 99 }); + expect(tooMany.status).toBe(400); + }); +}); + +// ─── Capacity + Rate limit caps ──────────────────────────────────── + +describe('POST /api/v1/rsvp/:token — caps', () => { + beforeEach(async () => { + await seedSnapshot(); + }); + + it('returns 429 once the per-token hourly rate limit is hit', async () => { + // TEST_CONFIG.rateLimit.rsvpPerTokenPerHour = 5 + const codes: number[] = []; + for (let i = 0; i < 7; i++) { + const res = await postRsvp({ name: `User${i}`, status: 'yes' }); + codes.push(res.status); + } + expect(codes.filter((c) => c === 200).length).toBe(5); + expect(codes.filter((c) => c === 429).length).toBe(2); + }); + + it('returns 429 once the absolute per-token max is reached', async () => { + // TEST_CONFIG.rateLimit.rsvpMaxPerToken = 20 + // We have to bypass the hourly limit (5/h) by writing rows directly, + // then the next POST should bounce off the absolute cap. + const rows = Array.from({ length: 20 }).map((_, i) => ({ + token: TOKEN, + name: `Bulk${i}`, + status: 'yes' as const, + })); + await app.db.execute(sql` + INSERT INTO events.public_rsvps (token, name, status) + SELECT * FROM jsonb_to_recordset(${JSON.stringify(rows)}::jsonb) + AS x(token text, name text, status text) + `); + + const res = await postRsvp({ name: 'OneMore', status: 'yes' }); + expect(res.status).toBe(429); + }); +}); diff --git a/services/mana-events/src/app.ts b/services/mana-events/src/app.ts new file mode 100644 index 000000000..87a7d47fc --- /dev/null +++ b/services/mana-events/src/app.ts @@ -0,0 +1,46 @@ +/** + * App factory — kept separate from index.ts so tests can import it + * without triggering the production bootstrap (sweeper, log, port bind). + */ + +import { Hono, type MiddlewareHandler } from 'hono'; +import { cors } from 'hono/cors'; +import type { Config } from './config'; +import type { Database } from './db/connection'; +import { errorHandler } from './middleware/error-handler'; +import { jwtAuth } from './middleware/jwt-auth'; +import { healthRoutes } from './routes/health'; +import { createEventsRoutes } from './routes/events'; +import { createRsvpRoutes } from './routes/rsvp'; + +/** + * Build the Hono app. The auth middleware is injected so tests can swap + * the real JWKS-validating jwtAuth for a header-based mock without + * spinning up a real mana-auth instance. + */ +export function createApp( + db: Database, + config: Config, + authMiddleware: MiddlewareHandler = jwtAuth(config.manaAuthUrl) +): Hono { + const app = new Hono(); + + app.onError(errorHandler); + app.use( + '*', + cors({ + origin: config.cors.origins, + credentials: true, + }) + ); + + // Public — no auth + app.route('/health', healthRoutes); + app.route('/api/v1/rsvp', createRsvpRoutes(db, config)); + + // Authenticated host endpoints + app.use('/api/v1/events/*', authMiddleware); + app.route('/api/v1/events', createEventsRoutes(db)); + + return app; +} diff --git a/services/mana-events/src/index.ts b/services/mana-events/src/index.ts index 9b882a7e7..4e22a04cb 100644 --- a/services/mana-events/src/index.ts +++ b/services/mana-events/src/index.ts @@ -5,48 +5,26 @@ * RSVP responses they collect. Hosts authenticate via mana-auth JWT; * RSVP endpoints are intentionally unauthenticated so anyone with a * share link can respond. + * + * The Hono app itself lives in app.ts so unit tests can import it + * without triggering the production bootstrap (sweeper, log, port). */ -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; +import { createApp } from './app'; import { loadConfig } from './config'; import { getDb } from './db/connection'; -import { errorHandler } from './middleware/error-handler'; -import { jwtAuth } from './middleware/jwt-auth'; -import { healthRoutes } from './routes/health'; -import { createEventsRoutes } from './routes/events'; -import { createRsvpRoutes } from './routes/rsvp'; import { startRateBucketSweeper } from './lib/cleanup'; const config = loadConfig(); const db = getDb(config.databaseUrl); -// Background cleanup of stale rate-limit buckets so they don't accumulate -// for the lifetime of long-published events. +// Background cleanup of stale rate-limit buckets so they don't +// accumulate for the lifetime of long-published events. startRateBucketSweeper(db); -const app = new Hono(); - -app.onError(errorHandler); -app.use( - '*', - cors({ - origin: config.cors.origins, - credentials: true, - }) -); - -// Public — no auth -app.route('/health', healthRoutes); -app.route('/api/v1/rsvp', createRsvpRoutes(db, config)); - -// Authenticated host endpoints -app.use('/api/v1/events/*', jwtAuth(config.manaAuthUrl)); -app.route('/api/v1/events', createEventsRoutes(db)); - console.log(`mana-events starting on port ${config.port}...`); export default { port: config.port, - fetch: app.fetch, + fetch: createApp(db, config).fetch, };