test(mana-events): 35 server tests covering routes + sweeper

Add bun:test integration suite that exercises every public and host
endpoint plus the rate-bucket sweeper against a real Postgres. The
Hono app factory was extracted from index.ts into app.ts so tests can
build their own instance with a header-based auth mock instead of
spinning up mana-auth + JWKS.

Coverage:
- health route smoke
- public RSVP: snapshot fetch (incl. 404, cancelled, summary
  privacy), submit, validation (name, status, email, plus-ones,
  cancelled), upsert dedup (incl. null/missing email parity), summary
  aggregation across yes/no/maybe + plus-ones, rate-limit cap (5/h),
  absolute per-token cap (20)
- host events: publish (auth, idempotent token reuse, ownership),
  snapshot update (partial, ownership, 404), delete (cascade FK to
  rsvps + buckets, ownership, idempotent), get rsvps (ownership)
- sweeper: removes >2h-old buckets, keeps fresh ones, no-op on empty

Mock auth lives in a small helper that injects an X-Test-User header
into a fake middleware, so the same createApp() factory powers both
production (real jwtAuth) and tests (header mock).
This commit is contained in:
Till JS 2026-04-07 19:02:54 +02:00
parent bed08a1aa6
commit 897256c985
8 changed files with 794 additions and 29 deletions

View file

@ -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"

View file

@ -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);
});
});

View file

@ -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<string, unknown> = {}) {
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 users 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 hosts 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 hosts 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);
});
});

View file

@ -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');
});
});

View file

@ -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> | Response;
wipe(): Promise<void>;
}
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<Config> = {}): 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);
}

View file

@ -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<typeof eventsPublished.$inferInsert> = {}) {
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<string, unknown>): Promise<Response> {
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);
});
});

View file

@ -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;
}

View file

@ -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,
};