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>
This commit is contained in:
274
tests/unit/website-inquiries.test.ts
Normal file
274
tests/unit/website-inquiries.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* /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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user