feat(api): /api/lead с SQLite и миграциями

db/: better-sqlite3 по DB_PATH (WAL, foreign_keys), миграции через
PRAGMA user_version, схема leads и lead_clarifications по плану.
createApp(deps) с инъекцией базы, часов и notifier (пока no-op, зовётся
после ответа). POST /api/lead: только JSON (415), тело до 16 КБ (413),
validateLead → 400, запись → 201. index.ts: env и graceful shutdown.
This commit is contained in:
2026-10-17 15:38:42 +03:00
parent a2885d180c
commit fa0964b24d
9 changed files with 412 additions and 3 deletions
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { MAX_BODY_BYTES } from './app.js';
import { flush, setup, T0, validBody } from './testing.js';
describe('GET /api/health', () => {
it('отдаёт версию', async () => {
const { app } = setup({ version: 'abc123' });
const res = await app.request('/api/health');
expect(await res.json()).toEqual({ ok: true, version: 'abc123' });
});
});
describe('POST /api/lead', () => {
it('201: сохраняет заявку с нормализованным телефоном, касаниями и user agent', async () => {
const { db, post } = setup();
const res = await post(validBody(), { 'user-agent': 'Mozilla/5.0 test' });
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ ok: true, duplicate: false });
const row = db.prepare('SELECT * FROM leads').get() as Record<string, unknown>;
expect(row).toMatchObject({
id: 1,
created_at: T0.toISOString(),
phone: '+375291234567',
name: 'Аня',
interest: 'subscription',
comment: 'Под эспрессо',
user_agent: 'Mozilla/5.0 test',
tg_status: 'pending',
tg_attempts: 0,
});
expect(JSON.parse(row.first_touch as string)).toMatchObject({ utm_source: 'instagram' });
expect(JSON.parse(row.last_touch as string)).toMatchObject({ utm_source: 'yandex', submitPage: '/#form' });
});
it('номер заявки клиенту не отдаёт', async () => {
const { post } = setup();
const text = await (await post(validBody())).text();
expect(text).toBe('{"ok":true,"duplicate":false}');
});
it('notifier получает id заявки уже после ответа', async () => {
const { post, events } = setup();
await post(validBody());
expect(events).toEqual([]);
await flush();
expect(events).toEqual([{ kind: 'lead', leadId: 1 }]);
});
it('упавший notifier не ломает ответ', async () => {
const { post, db } = setup({
notifier: {
leadSaved: () => Promise.reject(new Error('telegram down')),
},
});
const res = await post(validBody());
await flush();
expect(res.status).toBe(201);
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 1 });
});
it('400 validation: ошибки по полям, ничего не пишет', async () => {
const { post, db, events } = setup();
const res = await post(validBody({ name: ' ', phone: '+7 999 123-45-67' }));
expect(res.status).toBe(400);
const body = (await res.json()) as { fieldErrors: Record<string, string> };
expect(body).toMatchObject({ ok: false, error: 'validation' });
expect(Object.keys(body.fieldErrors).sort()).toEqual(['name', 'phone']);
await flush();
expect(events).toEqual([]);
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
});
it('400 bad_request: битый JSON', async () => {
const { post } = setup();
const res = await post('{"name":');
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
});
it('415: не JSON', async () => {
const { post } = setup();
const res = await post('name=Аня', { 'content-type': 'application/x-www-form-urlencoded' });
expect(res.status).toBe(415);
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
});
it('JSON с charset принимается', async () => {
const { post } = setup();
const res = await post(validBody(), { 'content-type': 'application/json; charset=utf-8' });
expect(res.status).toBe(201);
});
it('413: тело больше 16 КБ', async () => {
const { post, db } = setup();
const res = await post(validBody({ comment: 'а'.repeat(MAX_BODY_BYTES) }));
expect(res.status).toBe(413);
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
});
it('500 server: исключение при записи', async () => {
const { post, db } = setup();
db.close();
const res = await post(validBody());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ ok: false, error: 'server' });
});
});