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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user