Files
pn-new-crm/tests/unit/website-inquiries.test.ts
Matt Ciaccio 49d34e00c8 feat(website-intake): dual-write endpoint + migration chain repair
Adds website_submissions table + shared-secret POST endpoint so the
marketing site can dual-write inquiries alongside its NocoDB write.
Race-safe via INSERT ... ON CONFLICT, idempotent on submission_id,
refuses every request when WEBSITE_INTAKE_SECRET is unset. Also
repairs pre-existing 0020/0021/0022 prevId collision (renumbered +
journal re-sorted) so db:generate works again. 11 unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:52:33 +02:00

275 lines
9.0 KiB
TypeScript

/**
* /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<Record<string, unknown>>;
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<string, unknown>) => {
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<string, string> = {}) {
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);
});
});