mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
bed08a1aa6
commit
897256c985
8 changed files with 794 additions and 29 deletions
|
|
@ -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"
|
||||
|
|
|
|||
76
services/mana-events/src/__tests__/cleanup.test.ts
Normal file
76
services/mana-events/src/__tests__/cleanup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
303
services/mana-events/src/__tests__/events.test.ts
Normal file
303
services/mana-events/src/__tests__/events.test.ts
Normal 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 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);
|
||||
});
|
||||
});
|
||||
19
services/mana-events/src/__tests__/health.test.ts
Normal file
19
services/mana-events/src/__tests__/health.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
100
services/mana-events/src/__tests__/helpers.ts
Normal file
100
services/mana-events/src/__tests__/helpers.ts
Normal 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);
|
||||
}
|
||||
242
services/mana-events/src/__tests__/rsvp.test.ts
Normal file
242
services/mana-events/src/__tests__/rsvp.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
services/mana-events/src/app.ts
Normal file
46
services/mana-events/src/app.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue