/** * /api/public/website-inquiries route — unit tests. * * Asserts: * 1. Auth: rejects missing/wrong X-Webhook-Secret with 401. * 2. Validation: rejects malformed payloads (bad UUID, unknown kind, * unknown port) with 400. * 3. Idempotency: a repeat submission_id returns the existing row id * instead of inserting a duplicate. * * Uses HOISTED `vi.mock` (top of file) so mock state is established * before any module loads. Earlier attempts with `vi.doMock` inside * beforeEach proved flaky under parallel test-file execution because * other files' mocks leaked into ours via the shared module cache. * * The 503 path (WEBSITE_INTAKE_SECRET unset) is verified by manual * code inspection rather than a unit test - exercising it would * require runtime env mutation that conflicts with the hoisted mock. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const VALID_UUID = '11111111-1111-4111-8111-111111111111'; const SECRET = 'test-secret-at-least-16-chars-long'; // ─── Mock state — module-scoped so test-level mutations are visible ──── interface MockState { portRow: Array<{ id: string }>; existingRow: Array<{ id: string }>; inserted: Array>; rateLimitAllowed: boolean; /** Counts select(...).limit(...) calls made by the route within a test. * Call #1 = port lookup, call #2 = existing-submission lookup. Reset * in beforeEach so each test starts fresh. */ queryCount: number; } const state: MockState = { portRow: [{ id: 'port-uuid-port-nimara' }], existingRow: [], inserted: [], rateLimitAllowed: true, queryCount: 0, }; // ─── Hoisted mocks — apply for the entire file ──────────────────────── vi.mock('@/lib/env', () => ({ env: { WEBSITE_INTAKE_SECRET: SECRET }, })); vi.mock('@/lib/rate-limit', () => ({ rateLimiters: { publicForm: { limit: 10, window: 60_000 } }, checkRateLimit: vi.fn(async () => ({ allowed: state.rateLimitAllowed, resetAt: Date.now() + 60_000, })), })); vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); vi.mock('@/lib/db', () => { const selectChain = { from: () => selectChain, where: () => selectChain, limit: async () => { // First select call in a test = port lookup; second = existing // submission lookup (only reached on conflict path now). Counter // is reset by beforeEach. state.queryCount += 1; return state.queryCount === 1 ? state.portRow : state.existingRow; }, }; // Insert chain mirrors Drizzle's `insert(...).values(...).onConflictDoNothing(...).returning()`. // When `state.existingRow` is non-empty (simulated existing row), the // returning() resolves to []; the route then falls back to the SELECT // for the existing row id. Otherwise returning() yields the new row. const insertChain = { values: (vals: Record) => { const onConflictChain = { returning: async () => { if (state.existingRow.length > 0) { return []; // simulate conflict (no row inserted) } state.inserted.push(vals); return [{ id: 'generated-row-id' }]; }, }; return { onConflictDoNothing: () => onConflictChain, // Backwards-compat: tests that don't go through the conflict // path can still call .returning() directly. Same semantics. returning: async () => { state.inserted.push(vals); return [{ id: 'generated-row-id' }]; }, }; }, }; return { db: { select: () => selectChain, insert: () => insertChain, }, }; }); // ─── Helpers ────────────────────────────────────────────────────────── function makeReq(body: unknown, headers: Record = {}) { return { headers: { get(name: string) { return headers[name.toLowerCase()] ?? null; }, }, json: async () => body, } as unknown as import('next/server').NextRequest; } beforeEach(() => { state.portRow = [{ id: 'port-uuid-port-nimara' }]; state.existingRow = []; state.inserted = []; state.rateLimitAllowed = true; state.queryCount = 0; }); afterEach(() => { vi.clearAllMocks(); }); // ─── Tests ──────────────────────────────────────────────────────────── describe('POST /api/public/website-inquiries — auth + capture', () => { it('returns 401 when the X-Webhook-Secret header is missing', async () => { const { POST } = await import('@/app/api/public/website-inquiries/route'); const res = await POST(makeReq({})); expect(res.status).toBe(401); }); it('returns 401 when the X-Webhook-Secret header is wrong', async () => { const { POST } = await import('@/app/api/public/website-inquiries/route'); const res = await POST(makeReq({}, { 'x-webhook-secret': 'wrong-value-but-same-length-aaaa' })); expect(res.status).toBe(401); }); it('returns 400 when the body fails validation', async () => { const { POST } = await import('@/app/api/public/website-inquiries/route'); const res = await POST( makeReq( { submission_id: 'not-a-uuid', kind: 'berth_inquiry', payload: {} }, { 'x-webhook-secret': SECRET, }, ), ); expect(res.status).toBe(400); }); it('returns 400 when port_slug references a non-existent port', async () => { state.portRow = []; const { POST } = await import('@/app/api/public/website-inquiries/route'); const res = await POST( makeReq( { submission_id: VALID_UUID, kind: 'berth_inquiry', payload: { foo: 'bar' }, port_slug: 'no-such-port', }, { 'x-webhook-secret': SECRET }, ), ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/Unknown port/); }); it('captures a fresh submission and returns its id', async () => { const { POST } = await import('@/app/api/public/website-inquiries/route'); const res = await POST( makeReq( { submission_id: VALID_UUID, kind: 'berth_inquiry', payload: { firstName: 'Jane', email: 'jane@example.com' }, legacy_nocodb_id: '12345', }, { 'x-webhook-secret': SECRET, 'user-agent': 'TestAgent/1.0' }, ), ); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual({ id: 'generated-row-id', deduped: false }); expect(state.inserted).toHaveLength(1); const row = state.inserted[0]!; expect(row.submissionId).toBe(VALID_UUID); expect(row.kind).toBe('berth_inquiry'); expect(row.legacyNocodbId).toBe('12345'); expect(row.userAgent).toBe('TestAgent/1.0'); expect(row.payload).toEqual({ firstName: 'Jane', email: 'jane@example.com' }); }); it('returns 200 with deduped=true when the submission_id is already on file', async () => { state.existingRow = [{ id: 'existing-row-id' }]; const { POST } = await import('@/app/api/public/website-inquiries/route'); const res = await POST( makeReq( { submission_id: VALID_UUID, kind: 'contact_form', payload: {}, }, { 'x-webhook-secret': SECRET }, ), ); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual({ id: 'existing-row-id', deduped: true }); expect(state.inserted).toHaveLength(0); }); it('defaults port_slug to port-nimara when omitted', async () => { const { POST } = await import('@/app/api/public/website-inquiries/route'); const res = await POST( makeReq( { submission_id: VALID_UUID, kind: 'residence_inquiry', payload: { foo: 'bar' }, }, { 'x-webhook-secret': SECRET }, ), ); expect(res.status).toBe(200); expect(state.inserted).toHaveLength(1); expect(state.inserted[0]!.portId).toBe('port-uuid-port-nimara'); }); it('rejects unknown kinds', async () => { const { POST } = await import('@/app/api/public/website-inquiries/route'); const res = await POST( makeReq( { submission_id: VALID_UUID, kind: 'newsletter_signup', payload: {}, }, { 'x-webhook-secret': SECRET }, ), ); expect(res.status).toBe(400); }); it('returns 429 when the rate limiter trips', async () => { state.rateLimitAllowed = false; const { POST } = await import('@/app/api/public/website-inquiries/route'); const res = await POST( makeReq( { submission_id: VALID_UUID, kind: 'berth_inquiry', payload: {}, }, { 'x-webhook-secret': SECRET }, ), ); expect(res.status).toBe(429); }); });