275 lines
9.0 KiB
TypeScript
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);
|
||
|
|
});
|
||
|
|
});
|