rateLimit.ts: 5 запросов / 10 минут на IP в памяти, фиксированное окно, чистка устаревших записей по ходу запросов, 429 с Retry-After. IP из X-Real-IP только от loopback-сокета (плюс TRUSTED_PROXIES для шлюза docker), иначе адрес сокета; в базу IP не пишем. Honeypot → 201 без записи и уведомления. isPaused() → 503 на время восстановления базы. Тесты на каждый код ответа из таблицы плана.
132 lines
5.2 KiB
TypeScript
132 lines
5.2 KiB
TypeScript
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('honeypot: 201 как на обычную заявку, ничего не пишет, notifier молчит', async () => {
|
||
const { post, db, events } = setup();
|
||
const res = await post(validBody({ company: 'ООО Спам' }));
|
||
await flush();
|
||
expect(res.status).toBe(201);
|
||
expect(await res.json()).toEqual({ ok: true, duplicate: false });
|
||
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
|
||
expect(events).toEqual([]);
|
||
});
|
||
|
||
it('503 unavailable: приём на паузе (восстановление базы)', async () => {
|
||
let paused = true;
|
||
const { post, db } = setup({ isPaused: () => paused });
|
||
const res = await post(validBody());
|
||
expect(res.status).toBe(503);
|
||
expect(await res.json()).toEqual({ ok: false, error: 'unavailable' });
|
||
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
|
||
|
||
paused = false;
|
||
expect((await post(validBody())).status).toBe(201);
|
||
});
|
||
|
||
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' });
|
||
});
|
||
});
|