Эксперт по Отладке Кода

Находит ошибки в коде и выдаёт исправления с разбором причин, критичности и профилактикой.

// промпт
Действуй как senior-разработчик и эксперт по отладке на {{iazyk_programmirovaniia}}. Проведи дотошный разбор моего кода, найди все дефекты и предложи проверенные исправления с объяснениями. ## Контекст - **Язык и версия:** {{iazyk_programmirovaniia}} - **Стек и окружение:** {{freimvork_i_okruzenie}} - **Что код должен делать:** {{ozidaemoe_povedenie}} - **Что происходит на самом деле:** {{fakticeskoe_povedenie_i_osibka}} - **Воспроизведение:** {{sagi_vosproizvedeniia}} ## Код для отладки ``` {{kod}} ``` ## Порядок анализа 1. **Воспроизведи логически.** Прогони ход выполнения мысленно и укажи, на каком шаге поведение расходится с ожидаемым. 2. **Найди дефекты по категориям:** - синтаксис и опечатки; - логические ошибки и необработанные крайние случаи (пустые значения, границы, конкурентность); - исключения и сбои времени выполнения; - уязвимости безопасности и утечки ресурсов; - узкие места производительности; - «запахи» кода и антипаттерны. 3. **Не выдумывай.** Если причину нельзя установить по коду, прямо скажи об этом и укажи, какие данные или логи нужны. ## Что выдать по каждой проблеме - **Расположение** — строка или функция; - **Тип и критичность** — Высокая / Средняя / Низкая; - **Корневая причина** — почему это ломается, а не только симптом; - **Исправление** — готовый фрагмент с правкой; - **Профилактика** — тест, проверка или практика, чтобы это не повторилось. ## Формат ответа ```markdown ## Краткий вердикт [Одно-два предложения: главная причина сбоя] ## Найдено проблем: [Количество] ### #1 — [Тип Проблемы] · Критичность: [Уровень] - Расположение: [Строка Или Функция] - Причина: [Объяснение] - Исправление: ```{{iazyk_programmirovaniia}} {{ispravlennyi_kod}} ``` - Профилактика: [Совет] ``` В конце дай **итоговый исправленный код целиком** и список изменений. Тон: точный, без воды, с акцентом на лучшие практики и обучающие объяснения. Уровень детализации — {{uroven_detalizacii}}.
Заполните переменные

Пример запуска

Сгенерировано Claude Opus 5.5, 23 сентября 2026 г.

Значения переменных

Язык программирования
JavaScript (Node.js 20)
Фреймворк и окружение
Express 4.18, node-postgres (pg) 8.11, Stripe Node SDK 14, PostgreSQL 15, деплой на Render.com (staging-окружение)
Ожидаемое поведение
1) Webhook /api/stripe-webhook должен проверять подпись Stripe, обрабатывать событие payment_intent.succeeded ровно один раз (даже если Stripe присылает повторную доставку того же event.id), помечать заказ оплаченным и отправлять письмо-квитанцию один раз. 2) Соединение с БД должно корректно возвращаться в пул после каждого запроса. 3) GET /api/orders?page=2&limit=10 должен возвращать записи 11-20 без пропусков и дублей относительно page=1.
Фактическое поведение и ошибка
Stripe ретраит доставку вебхука (обычная практика при таймауте ответа >3с), и заказ помечается оплаченным по несколько раз, клиенту уходит 2-3 одинаковых письма с квитанцией. В логах Render иногда падает необработанный reject: 'UnhandledPromiseRejectionWarning: Error: sorry, too many clients already' — похоже, соединения pg не освобождаются под нагрузкой. Отдельно на странице заказов: page=1 отдаёт id 1-10, но page=2 отдаёт id 10-19 (id=10 дублируется, id=20 пропадает).
Шаги воспроизведения
1. Запустить сервер локально (node server.js) с проброшенным вебхуком через `stripe listen --forward-to localhost:3000/api/stripe-webhook`. 2. Выполнить `stripe trigger payment_intent.succeeded` дважды подряд для одного и того же payment_intent (через `stripe events resend`). 3. Проверить таблицу orders — статус меняется, письмо в консоли логируется дважды. 4. Дать 50 параллельных запросов через `autocannon -c 50 -d 10 http://localhost:3000/api/orders` — через 10-15 секунд появляется ошибка 'too many clients already'. 5. Вызвать GET /api/orders?page=1&limit=10 и GET /api/orders?page=2&limit=10, сравнить массивы id — обнаружится пересечение и пропуск.
Код
const express = require('express'); const { Pool } = require('pg'); const Stripe = require('stripe'); const nodemailer = require('nodemailer'); const app = express(); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const stripe = Stripe(process.env.STRIPE_SECRET_KEY); app.use(express.json()); app.post('/api/stripe-webhook', async (req, res) => { const event = req.body; if (event.type === 'payment_intent.succeeded') { const orderId = event.data.object.metadata.order_id; const client = await pool.connect(); const query = "UPDATE orders SET status = 'paid' WHERE id = " + orderId; client.query(query); const order = await client.query( "SELECT * FROM orders WHERE id = " + orderId ); sendReceiptEmail(order.rows[0]); } res.sendStatus(200); }); async function sendReceiptEmail(order) { const transporter = nodemailer.createTransport({ /* smtp config */ }); await transporter.sendMail({ to: order.customer_email, subject: 'Ваш заказ оплачен', text: `Заказ #${order.id} успешно оплачен на сумму ${order.amount}` }); } app.get('/api/orders', async (req, res) => { const page = req.query.page || 1; const limit = req.query.limit || 10; const offset = page * limit; const result = await pool.query( `SELECT * FROM orders ORDER BY id LIMIT ${limit} OFFSET ${offset}` ); res.json(result.rows); }); app.listen(3000, () => console.log('Server started on port 3000'));
Исправленный код
const express = require('express'); const { Pool } = require('pg'); const Stripe = require('stripe'); const nodemailer = require('nodemailer'); const app = express(); const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); const stripe = Stripe(process.env.STRIPE_SECRET_KEY); // Важно: raw body нужен для проверки подписи Stripe, express.json() применяем только к остальным маршрутам app.post( '/api/stripe-webhook', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { const signature = req.headers['stripe-signature']; event = stripe.webhooks.constructEvent( req.body, signature, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } if (event.type === 'payment_intent.succeeded') { const orderId = event.data.object.metadata.order_id; const client = await pool.connect(); try { await client.query('BEGIN'); // Идемпотентность: пропускаем уже обработанные event.id const already = await client.query( 'SELECT 1 FROM processed_webhook_events WHERE event_id = $1', [event.id] ); if (already.rowCount > 0) { await client.query('ROLLBACK'); return res.sendStatus(200); } await client.query( 'INSERT INTO processed_webhook_events (event_id, processed_at) VALUES ($1, now())', [event.id] ); const updated = await client.query( "UPDATE orders SET status = 'paid' WHERE id = $1 AND status <> 'paid' RETURNING *", [orderId] ); await client.query('COMMIT'); if (updated.rowCount > 0) { await sendReceiptEmail(updated.rows[0]); } } catch (err) { await client.query('ROLLBACK'); console.error('Webhook processing error:', err); return res.sendStatus(500); } finally { client.release(); } } res.sendStatus(200); } ); app.use(express.json()); async function sendReceiptEmail(order) { const transporter = nodemailer.createTransport({ /* smtp config */ }); await transporter.sendMail({ to: order.customer_email, subject: 'Ваш заказ оплачен', text: `Заказ #${order.id} успешно оплачен на сумму ${order.amount}` }); } app.get('/api/orders', async (req, res) => { const page = Math.max(1, parseInt(req.query.page, 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 10)); const offset = (page - 1) * limit; const result = await pool.query( 'SELECT * FROM orders ORDER BY id LIMIT $1 OFFSET $2', [limit, offset] ); res.json(result.rows); }); app.listen(3000, () => console.log('Server started on port 3000'));
Уровень детализации
Высокий: построчный разбор, объяснение каждой первопричины, готовые фрагменты кода и рекомендации по тестам/мониторингу

Краткий вердикт

Письма дублируются, потому что обработчик вебхука не идемпотентен. Он не запоминает обработанный event.id, не проверяет, был ли заказ уже оплачен, и отправляет письмо безусловно. Кроме того, каждый вызов вебхука навсегда забирает соединение из пула: pool.connect() вызывается без client.release(). После ~10 вызовов пул исчерпывается, запросы зависают, Stripe получает таймауты и присылает событие повторно. Пагинация сломана формулой offset = page * limit, а оба эндпоинта уязвимы к SQL-инъекциям. Вебхук вдобавок принимает неподписанные запросы, так что любой может отметить любой заказ оплаченным.

Два симптома из описания не выводятся из присланного кода: «10–19» на page=2 и текст UnhandledPromiseRejectionWarning на Node 20. Разбор и список нужных данных — в разделе «Что нельзя установить по коду».


0. Логическое воспроизведение

Сценарий A: вебхук и повторная доставка

Шаг Что делает код Что должно быть Расхождение
1 app.use(express.json()) парсит тело во всех маршрутах Для вебхука нужно сырое тело (Buffer), иначе подпись не проверить Сырое тело потеряно; подпись и не проверяется
2 const event = req.body, доверие любому JSON stripe.webhooks.constructEvent(raw, sig, secret) Первое расхождение: источник события не проверен
3 event.data.object.metadata.order_id Проверить наличие и формат stripe trigger payment_intent.succeeded создаёт PaymentIntent без metadata.order_id, получается undefined
4 await pool.connect() Взять клиента и гарантированно вернуть Клиент не возвращается никогда
5 client.query("UPDATE ... WHERE id = " + orderId) без await Дождаться результата внутри транзакции При orderId = undefined SQL WHERE id = undefined падает с column "undefined" does not exist, и этот промис никто не обрабатывает
6 await client.query("SELECT ...") — node-postgres ставит запросы одного клиента в очередь, поэтому SELECT увидит результат UPDATE. Если UPDATE упал, SELECT вернёт строку в старом состоянии
7 sendReceiptEmail(order.rows[0]) без await и без проверки Отправить один раз, только при реальной смене статуса Второе расхождение: письмо уходит при каждой доставке. Если строки нет, order.customer_email даёт TypeError и ещё один необработанный reject
8 res.sendStatus(200) Быстрый 2xx Пока пул жив, ответ быстрый. Когда пул исчерпан (шаг 4), pool.connect() висит бесконечно (нет connectionTimeoutMillis), Stripe получает таймаут и ретраит

При stripe events resend <evt_id> приходит тот же event.id. Код его не запоминает, поэтому UPDATE выполняется повторно (безвредно, но без смысла), а письмо уходит повторно (это и есть баг).

Сценарий B: пагинация

req.query.page и req.query.limit — строки. Умножение приводит их к числам: "1" * "10" = 10.

Запрос offset в коде Что вернёт код (при id 1..N без пропусков) Ожидалось
page=1&limit=10 10 id 11–20 id 1–10
page=2&limit=10 20 id 21–30 id 11–20

Код систематически пропускает первую страницу. Наблюдаемое «page=1 → 1–10, page=2 → 10–19» этим кодом не воспроизводится. Гипотезы ниже, в проблеме #8.

Сценарий C: autocannon на /api/orders

pool.query() сам берёт и возвращает клиента, поэтому этот маршрут соединения не течёт. Пул по умолчанию ограничен max: 10 на процесс. Один процесс с этим кодом не может открыть к Postgres больше 10 соединений, а ошибка sorry, too many clients already приходит от сервера Postgres при превышении max_connections. Значит, соединения открывает кто-то ещё: несколько инстансов или процессов, другие сервисы на той же БД, зависшие сессии после падений. Утечка в вебхуке даёт другой симптом: зависание запросов в очереди пула. Подробности — в #12.


Найдено проблем: 16

#1 — Безопасность: нет проверки подписи Stripe · Критичность: Критическая

  • Расположение: app.use(express.json()) и первая строка обработчика /api/stripe-webhook.
  • Причина: обработчик верит любому POST. Одного curl -X POST .../api/stripe-webhook -d '{"type":"payment_intent.succeeded","data":{"object":{"metadata":{"order_id":"42"}}}}' достаточно, чтобы отметить заказ оплаченным без оплаты. Подпись Stripe-Signature вычисляется по байтам тела. После express.json() байты потеряны, и JSON.stringify(req.body) их не восстановит: порядок ключей, пробелы и unicode-экранирование могут отличаться. Поэтому express.raw() должен стоять до глобального express.json() или только на этом маршруте.
  • Исправление:
    // Маршрут вебхука регистрируется ДО app.use(express.json())
    app.post(
      '/api/stripe-webhook',
      express.raw({ type: 'application/json', limit: '1mb' }),
      asyncHandler(async (req, res) => {
        let event;
        try {
          event = stripe.webhooks.constructEvent(
            req.body,                          // Buffer, не объект
            req.headers['stripe-signature'],
            process.env.STRIPE_WEBHOOK_SECRET  // whsec_...
          );
        } catch (err) {
          console.warn('Stripe signature verification failed:', err.message);
          return res.status(400).send('Invalid signature');
        }
        // ... обработка
      })
    );
    
    app.use(express.json()); // для всех остальных маршрутов
    
  • Профилактика: интеграционный тест должен ожидать 400 для неподписанного и испорченного тела и 200 для тела, подписанного через stripe.webhooks.generateTestHeaderString. Учтите, что stripe listen выдаёт свой whsec_..., отличный от секрета эндпоинта в Dashboard. Держите их в разных env-переменных для local и staging.

#2 — Логика/конкурентность: нет идемпотентности · Критичность: Высокая

  • Расположение: весь блок if (event.type === 'payment_intent.succeeded').

  • Причина: Stripe гарантирует доставку at-least-once, так что дубли — штатная ситуация. Код не хранит event.id и не проверяет, изменил ли UPDATE хоть что-то. Письмо отправляется при каждой доставке. Дубли бывают двух видов, и нужны оба уровня защиты:

    1. Тот же event.id (ретрай, stripe events resend) закрывается таблицей обработанных событий с PRIMARY KEY.
    2. Разные события о той же оплате (редко, но возможно) закрываются условием WHERE status IS DISTINCT FROM 'paid' и UNIQUE (order_id, kind) в outbox писем.

    Вставка должна быть атомарной: INSERT ... ON CONFLICT DO NOTHING RETURNING. Схема «сначала SELECT, потом INSERT» даёт гонку: два параллельных запроса оба видят «ещё нет».

  • Исправление:

    -- миграция
    CREATE TABLE IF NOT EXISTS processed_webhook_events (
      event_id     text PRIMARY KEY,
      event_type   text NOT NULL,
      processed_at timestamptz NOT NULL DEFAULT now()
    );
    
    const outcome = await withTransaction(async (client) => {
      const inserted = await client.query(
        `INSERT INTO processed_webhook_events (event_id, event_type)
         VALUES ($1, $2)
         ON CONFLICT (event_id) DO NOTHING
         RETURNING event_id`,
        [event.id, event.type]
      );
      if (inserted.rowCount === 0) return { status: 'duplicate_event' };
    
      const updated = await client.query(
        `UPDATE orders
            SET status = 'paid'
          WHERE id = $1 AND status IS DISTINCT FROM 'paid'
          RETURNING id, amount, customer_email`,
        [orderId]
      );
      if (updated.rowCount === 0) return { status: 'already_paid_or_missing' };
    
      // письмо ставится в очередь в ТОЙ ЖЕ транзакции (см. #10)
      await client.query(
        `INSERT INTO email_outbox (order_id, kind, payload)
         VALUES ($1, 'receipt', $2)
         ON CONFLICT (order_id, kind) DO NOTHING`,
        [updated.rows[0].id, JSON.stringify(updated.rows[0])]
      );
      return { status: 'paid' };
    });
    

    Почему это корректно при параллельных доставках: второй INSERT с тем же event_id блокируется на уникальном индексе, пока первая транзакция не завершится. После COMMIT первой срабатывает DO NOTHING. Если первая откатилась, вторая спокойно продолжает. IS DISTINCT FROM вместо <> нужен, потому что NULL <> 'paid' даёт NULL, а не true, и заказ с status = NULL никогда бы не обновился.

  • Профилактика: тест «одно подписанное событие, 5 параллельных POST через Promise.all». Ожидается ровно одна строка в processed_webhook_events, одна в email_outbox, все ответы 200.


#3 — Утечка ресурсов: клиент пула не освобождается · Критичность: Высокая

  • Расположение: const client = await pool.connect(); в вебхуке.
  • Причина: client.release() не вызывается ни на успешном пути, ни при ошибке. Каждый вызов вебхука навсегда занимает слот пула (по умолчанию max: 10). После 10 вызовов любой pool.connect() и pool.query() ждёт в очереди бесконечно, потому что по умолчанию connectionTimeoutMillis: 0. Приложение перестаёт отвечать, а Stripe, не получив ответа, ретраит, что ещё и усиливает дубли.
  • Исправление: всё, что работает с выделенным клиентом, проходит через одну функцию с finally:
    async function withTransaction(fn) {
      const client = await pool.connect();
      let brokenConnectionError;
      try {
        await client.query('BEGIN');
        const result = await fn(client);
        await client.query('COMMIT');
        return result;
      } catch (err) {
        try {
          await client.query('ROLLBACK');
        } catch (rollbackErr) {
          // соединение сломано и не должно вернуться в пул
          brokenConnectionError = rollbackErr;
        }
        throw err;
      } finally {
        // release(err) с truthy-аргументом уничтожает клиента, а не возвращает его в пул
        client.release(brokenConnectionError);
      }
    }
    
    Если выделенный клиент не нужен (одиночный запрос), используйте pool.query(): он освобождает клиента сам.
  • Профилактика: в тестах после N вызовов проверять pool.totalCount - pool.idleCount === 0 и pool.waitingCount === 0. В проде периодически логировать pool.totalCount / idleCount / waitingCount и алертить на waitingCount > 0 дольше 30 с. Правило для code review: каждый pool.connect() должен иметь release() в finally.

#4 — Runtime/логика: client.query без await, нет транзакции · Критичность: Высокая

  • Расположение: client.query(query); (UPDATE).
  • Причина: промис UPDATE никто не ждёт и не обрабатывает. Ошибка UPDATE (как в WHERE id = undefined) становится unhandled rejection, а в Node 20 это по умолчанию падение процесса (--unhandled-rejections=throw). Порядок выполнения при этом не ломается: node-postgres последовательно выполняет запросы одного клиента. Ломается обработка ошибок. К тому же UPDATE и последующие действия не атомарны: при сбое между ними получится «оплачено, но письмо не поставлено» или наоборот.
  • Исправление: все запросы через await внутри withTransaction (#3). SELECT после UPDATE не нужен, так как RETURNING возвращает строку за один запрос (#2).
  • Профилактика: ESLint-правила @typescript-eslint/no-floating-promises (работает и для JS с checkJs) или promise/catch-or-return и require-await.

#5 — Исключения: async-обработчики в Express 4 без обработки ошибок · Критичность: Высокая

  • Расположение: оба маршрута (async (req, res) => {...}).
  • Причина: Express 4 не перехватывает reject промиса, возвращённого обработчиком (это появилось только в Express 5). Любая ошибка pool.connect() или pool.query(), включая too many clients already, превращается в unhandled rejection. Ответ клиенту не отправляется (запрос висит до таймаута), а Node 20 завершает процесс. Под нагрузкой autocannon это выглядит как лавина ошибок и рестарты инстанса на Render.
  • Исправление:
    const asyncHandler = (fn) => (req, res, next) =>
      Promise.resolve(fn(req, res, next)).catch(next);
    
    // последним middleware
    app.use((err, req, res, next) => {
      console.error(`[${req.method} ${req.originalUrl}]`, err);
      if (res.headersSent) return next(err);
      res.status(500).json({ error: 'Internal Server Error' });
    });
    
  • Профилактика: тест, где pool.query замокан на reject: ожидается 500 и живой процесс. Альтернатива — express-async-errors или переход на Express 5.

#6 — Безопасность: SQL-инъекция в вебхуке · Критичность: Высокая

  • Расположение: "UPDATE orders SET status = 'paid' WHERE id = " + orderId и SELECT.
  • Причина: значение из тела запроса конкатенируется в SQL. Без проверки подписи (#1) атакующий полностью контролирует metadata.order_id. Например, "1 OR 1=1" пометит оплаченными все заказы. Запрос без параметров идёт через simple query protocol, который допускает несколько операторов через ;.
  • Исправление: только параметризованные запросы ($1) плюс валидация формата:
    function parseOrderId(raw) {
      // предполагаю целочисленный serial/bigserial id (из примера "id 1-10").
      // Если id — uuid, замените проверку на регулярку uuid.
      if (typeof raw !== 'string' || !/^[1-9]\d{0,17}$/.test(raw)) return null;
      return raw; // строкой: безопасно для bigint, pg приведёт тип сам
    }
    
  • Профилактика: запрет конкатенации и шаблонных строк внутри .query( (правило ESLint no-restricted-syntax на TemplateLiteral и BinaryExpression в аргументе query), семгреп-правило в CI.

#7 — Безопасность: SQL-инъекция в GET /api/orders · Критичность: Критическая

  • Расположение: `SELECT * FROM orders ORDER BY id LIMIT ${limit} OFFSET ${offset}`.
  • Причина: limit подставляется из query-строки как есть. ?limit=1;DROP TABLE orders;-- приводит к выполнению двух операторов, потому что pool.query(text) без массива параметров использует simple protocol и разрешает мульти-стейтменты. offset при нечисловом page станет NaN, но limit опасен сам по себе. Эндпоинт публичный, без аутентификации.
  • Исправление:
    const { rows } = await pool.query(
      'SELECT id, status, amount, customer_email FROM orders ORDER BY id LIMIT $1 OFFSET $2',
      [limit, offset]
    );
    
    С массивом параметров pg использует extended protocol, где несколько операторов невозможны в принципе.
  • Профилактика: тест GET /api/orders?limit=1;SELECT%201 должен вернуть 400. Тот же lint-запрет, что в #6.

#8 — Логика: неверная формула offset · Критичность: Высокая

  • Расположение: const offset = page * limit;.

  • Причина: страницы нумеруются с 1, поэтому смещение равно (page - 1) * limit. Текущая формула пропускает первую страницу целиком (см. таблицу в разделе 0). Важно: описанный симптом («page=2 → id 10–19», id 10 дублируется, 20 пропадает) из этого кода не получается. Он соответствует смещению на 1 строку. Самые вероятные объяснения, которые нужно проверить на staging:

    1. На Render задеплоена другая версия кода, например курсорная пагинация с WHERE id >= $lastId вместо >. Это даёт ровно «последний id предыдущей страницы повторяется, последний id текущей теряется».
    2. Клиент (фронтенд) передаёт не page, а смещение или курсор с off-by-one.
    3. ORDER BY в задеплоенной версии идёт по неуникальному столбцу (например, created_at). Тогда порядок между страницами не детерминирован и дубли и пропуски случайны.

    Кроме того, «записи 11–20» — это позиции, а не значения id. При пропусках в последовательности (удаления, откаты транзакций, serial не заполняет «дыры») id на странице 2 не обязаны быть 11..20.

  • Исправление:

    const offset = (page - 1) * limit;
    

    Плюс валидация из #9 и опциональный keyset-режим из #16.

  • Профилактика: тест «засеять 25 строк; page1 ∩ page2 = ∅; page1 ∪ page2 = первые 20 по ORDER BY id; page3 имеет длину 5». Чтобы проверить гипотезу 1, сравните commit SHA в Render Dashboard (Events → Deploy) с локальным git rev-parse HEAD и включите на staging log_min_duration_statement = 0 на время отладки, чтобы увидеть фактический SQL.


#9 — Крайние случаи: нет валидации page/limit · Критичность: Средняя

  • Расположение: req.query.page || 1, req.query.limit || 10.
  • Причина:
    • page=abc даёт offset = NaN, SQL OFFSET NaN падает, и вместе с #5 это unhandled rejection.
    • page=0 или page=-5 дают отрицательный OFFSET, это ошибка Postgres.
    • limit=10000000 позволяет выгрузить всю таблицу, это DoS и утечка данных.
    • ?page=1&page=2 превращает req.query.page в массив (парсер qs).
    • limit=0 из-за || превращается в 10, что само по себе неочевидно.
  • Исправление:
    function parseIntParam(raw, { def, min, max }) {
      if (raw === undefined || raw === '') return def;
      if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null;
      const n = Number(raw);
      if (!Number.isSafeInteger(n) || n < min || n > max) return null;
      return n;
    }
    
    const limit = parseIntParam(req.query.limit, { def: 10, min: 1, max: 100 });
    const page  = parseIntParam(req.query.page,  { def: 1,  min: 1, max: 100000 });
    if (limit === null || page === null) {
      return res.status(400).json({ error: 'page and limit must be positive integers (limit ≤ 100)' });
    }
    
  • Профилактика: табличный (parametrized) тест на значения abc, -1, 0, 1.5, 1e3, массив и 101: все дают 400.

#10 — Надёжность: отправка письма fire-and-forget и вне транзакции · Критичность: Высокая

  • Расположение: sendReceiptEmail(order.rows[0]); и сама функция.
  • Причина:
    1. Вызов без await и без .catch: ошибка SMTP становится unhandled rejection и роняет процесс.
    2. order.rows[0] бывает undefined (заказ не найден), тогда order.customer_email бросает TypeError.
    3. nodemailer.createTransport() создаётся на каждое письмо: новое TCP/TLS-соединение и никакого пулинга.
    4. Письмо не связано с фактом оплаты атомарно. Если отправлять синхронно внутри вебхука, SMTP-задержка тянет время ответа, Stripe получает таймаут и ретраит (источник дублей). Если отправлять после COMMIT, но до ответа, и SMTP упал, событие уже отмечено обработанным, ретрай его пропустит, и письмо потеряется навсегда.
  • Исправление: паттерн Transactional Outbox. Письмо ставится в очередь в той же транзакции, что и смена статуса (#2). Вебхук сразу отвечает 200, а отдельный воркер отправляет письма с lease-блокировкой. Во время SMTP-вызова воркер не держит соединение с БД.
    CREATE TABLE IF NOT EXISTS email_outbox (
      id           bigserial PRIMARY KEY,
      order_id     bigint NOT NULL,
      kind         text   NOT NULL,
      payload      jsonb  NOT NULL,
      attempts     int    NOT NULL DEFAULT 0,
      locked_until timestamptz,
      last_error   text,
      sent_at      timestamptz,
      created_at   timestamptz NOT NULL DEFAULT now(),
      UNIQUE (order_id, kind)  -- второй рубеж: одна квитанция на заказ
    );
    CREATE INDEX IF NOT EXISTS email_outbox_pending_idx
      ON email_outbox (id) WHERE sent_at IS NULL;
    
    const transporter = nodemailer.createTransport({ /* smtp config */ pool: true });
    
    async function processEmailOutbox() {
      const { rows } = await pool.query(
        `UPDATE email_outbox
            SET locked_until = now() + interval '2 minutes',
                attempts = attempts + 1
          WHERE id IN (
            SELECT id FROM email_outbox
             WHERE sent_at IS NULL
               AND attempts < 5
               AND (locked_until IS NULL OR locked_until < now())
             ORDER BY id
             LIMIT 10
             FOR UPDATE SKIP LOCKED)
          RETURNING id, payload`
      );
      for (const row of rows) {
        try {
          await transporter.sendMail(buildReceipt(row.payload));
          await pool.query('UPDATE email_outbox SET sent_at = now(), locked_until = NULL WHERE id = $1', [row.id]);
        } catch (err) {
          await pool.query('UPDATE email_outbox SET last_error = $2, locked_until = NULL WHERE id = $1',
            [row.id, String(err && err.message).slice(0, 500)]);
        }
      }
    }
    
    Честная оговорка: «ровно один раз» для SMTP в строгом смысле недостижимо. Если процесс упадёт между успешным sendMail и UPDATE ... sent_at, письмо уйдёт повторно после истечения lease. Outbox сводит окно дубля к этим миллисекундам и гарантирует, что письмо не потеряется. Дубли из-за ретраев Stripe при этом исключены полностью.
  • Профилактика: тест «SMTP-мок падает на первой попытке»: ожидается attempts = 1, sent_at IS NULL, после второго прогона sent_at заполнен. Метрика и алерт на count(*) WHERE sent_at IS NULL AND created_at < now() - interval '10 minutes' и на attempts >= 5.

#11 — Крайние случаи: нет metadata.order_id, заказ не найден · Критичность: Средняя

  • Расположение: event.data.object.metadata.order_id.
  • Причина: шаг 2 вашего сценария (stripe trigger payment_intent.succeeded) создаёт PaymentIntent без вашей метаданной. В исходном коде это даёт WHERE id = undefined, SQL-ошибку и падение. В реальных данных такое тоже бывает: PaymentIntent создан не вашим кодом, например вручную в Dashboard. Отвечать 500 на такие события бессмысленно: Stripe будет ретраить их несколько дней, а результат не изменится.
  • Исправление:
    const orderId = parseOrderId(event.data.object?.metadata?.order_id);
    if (orderId === null) {
      console.error('payment_intent.succeeded without valid metadata.order_id', {
        eventId: event.id, paymentIntent: event.data.object?.id,
      });
      return res.sendStatus(200); // ack: повтор не поможет; нужен алерт, а не ретрай
    }
    
    Для воспроизведения с корректными данными передайте метаданные: stripe trigger payment_intent.succeeded --add payment_intent:metadata.order_id=42. Точный синтаксис --add / --override сверьте с stripe trigger --help вашей версии CLI.
  • Профилактика: тест подписанного события без metadata ожидает 200, отсутствие изменений в БД и запись в лог. На staging настройте алерт на эту строку лога.

#12 — Ресурсы/конфигурация: пул не настроен; причина «too many clients» · Критичность: Средняя (диагноз частично требует данных)

  • Расположение: new Pool({ connectionString }).
  • Причина:
    • Нет connectionTimeoutMillis: при исчерпании пула запросы ждут бесконечно, а не падают быстро.
    • Нет pool.on('error'): ошибка на простаивающем клиенте (рестарт или failover БД, обрыв сети) генерирует событие 'error' без обработчика и роняет процесс.
    • Нет statement_timeout: медленный запрос держит соединение сколько угодно.
    • max не согласован с max_connections сервера. Нужно обеспечить инстансы × max + прочие клиенты < max_connections − superuser_reserved_connections. На Render число доступных соединений зависит от плана Postgres. Точное значение проверьте через SHOW max_connections;.
  • Исправление:
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
      max: Number(process.env.PG_POOL_MAX) || 10,
      idleTimeoutMillis: 30_000,
      connectionTimeoutMillis: 5_000,  // быстрый отказ вместо вечного ожидания
      statement_timeout: 10_000,       // серверный таймаут запроса
    });
    pool.on('error', (err) => console.error('Unexpected idle pg client error', err));
    
  • Что нужно для точного диагноза (см. последний раздел): вывод запроса ниже в момент ошибки, число инстансов сервиса на Render и список других сервисов, подключённых к той же БД.
    SELECT application_name, client_addr, state, count(*)
      FROM pg_stat_activity
     WHERE datname = current_database()
     GROUP BY 1, 2, 3
     ORDER BY 4 DESC;
    
    Чтобы различать свои соединения, задайте application_name в строке подключения (?application_name=orders-api) или в конфиге Pool.
  • Профилактика: периодический лог метрик пула, алерт на numbackends из pg_stat_database выше 80% от max_connections. При многих инстансах поставьте PgBouncer в режиме transaction pooling.

#13 — Логика платежей: нет сверки суммы и допустимых переходов статуса · Критичность: Средняя

  • Расположение: UPDATE заказа в вебхуке.
  • Причина: заказ помечается оплаченным по одному факту payment_intent.succeeded с его order_id. Сумма и валюта не сверяются. Если PaymentIntent может быть создан с произвольной суммой (например, клиентская логика или повторное использование order_id), заказ на 10 000 «оплачивается» платежом на 1. Также не ограничены переходы: заказ в статусе cancelled/refunded (если такие есть) будет перезаписан на paid. Схему статусов и единицы orders.amount по коду установить нельзя, поэтому в итоговый код проверка включена в виде помеченного TODO.
  • Исправление (адаптировать под схему):
    // Предположение: orders.amount хранится в минимальных единицах валюты (копейки/центы)
    // и в orders есть столбец currency. Если нет — скорректируйте.
    const pi = event.data.object;
    const updated = await client.query(
      `UPDATE orders SET status = 'paid'
        WHERE id = $1 AND status = 'pending'
          AND amount = $2 AND currency = $3
        RETURNING id, amount, customer_email`,
      [orderId, pi.amount_received, pi.currency]
    );
    
  • Профилактика: тест «событие с суммой меньше суммы заказа» ожидает, что статус не меняется и появляется алерт. Храните payment_intent.id в заказе (столбец с UNIQUE) для аудита и сверки.

#14 — Безопасность данных: SELECT *, PII, нет авторизации · Критичность: Средняя (требует проверки)

  • Расположение: GET /api/orders.
  • Причина: в присланном коде эндпоинт отдаёт все заказы всех клиентов вместе с customer_email без аутентификации. Возможно, защита стоит перед приложением (прокси, отдельный middleware), но в коде её нет, и проверить это нельзя. SELECT * отдаёт наружу любые столбцы, которые добавят в таблицу позже.
  • Исправление: явный список столбцов (в итоговом коде: известные из исходника id, status, amount, customer_email) и middleware аутентификации с фильтром WHERE customer_id = $current_user для клиентского API.
  • Профилактика: тест «запрос без токена возвращает 401». Схема ответа через DTO или сериализатор, а не сырые строки БД.

#15 — Эксплуатация: порт, graceful shutdown, проверка env · Критичность: Низкая

  • Расположение: app.listen(3000, ...), инициализация.
  • Причина:
    • Render передаёт порт через process.env.PORT. Жёсткий 3000 работает только благодаря автоопределению порта, что хрупко.
    • Нет обработки SIGTERM: при деплое Render останавливает инстанс, и незавершённые запросы и транзакции обрываются, а соединения закрываются некорректно.
    • Отсутствие STRIPE_WEBHOOK_SECRET или DATABASE_URL обнаружится только на первом запросе.
    • Без явного apiVersion форма объектов событий зависит от версии API аккаунта и SDK.
  • Исправление: см. итоговый код: requireEnv, process.env.PORT || 3000, обработчик SIGTERM с server.close() и pool.end(), apiVersion через env.
  • Профилактика: smoke-тест старта без env ожидает немедленное падение с понятным сообщением. Health-check эндпоинт GET /healthz с SELECT 1.

#16 — Производительность/стабильность: OFFSET-пагинация · Критичность: Низкая

  • Расположение: LIMIT ... OFFSET ....
  • Причина: Postgres читает и отбрасывает OFFSET строк, поэтому глубокие страницы работают за O(offset). Если между запросами page=1 и page=2 удаляется строка с первой страницы, одна запись пропадает: всё сдвигается на 1. При ORDER BY id вставки новых строк (с большими id) первые страницы не сдвигают, а удаления сдвигают.
  • Исправление: keyset-пагинация, опционально и обратно совместимо:
    // GET /api/orders?after_id=10&limit=10  →  id > 10, строго «>», не «>=»
    const { rows } = await pool.query(
      'SELECT id, status, amount, customer_email FROM orders WHERE id > $1 ORDER BY id LIMIT $2',
      [afterId, limit]
    );
    
  • Профилактика: тест «удалить строку из первой страницы между запросами»: keyset не теряет записи.

Замечания к черновику исправления в шаблоне

В шаблоне приведён черновой вариант фикса. Он правильный по направлению (raw body, подпись, параметры, (page - 1), finally release), но в нём остались дефекты:

  1. Гонка SELECT → INSERT в идемпотентности. Два параллельных запроса оба видят «нет записи». Нужен INSERT ... ON CONFLICT DO NOTHING RETURNING (#2).
  2. Потеря письма. sendReceiptEmail вызывается после COMMIT. Если SMTP упадёт, catch вернёт 500, Stripe повторит доставку, но event.id уже записан, и письмо не уйдёт никогда. Кроме того, SMTP внутри запроса увеличивает время ответа, а это и есть триггер ретраев (#10).
  3. pool.connect() вне try. Ошибка подключения становится unhandled rejection, и Express 4 её не поймает (#5).
  4. ROLLBACK в catch может сам бросить, если соединение умерло. Исходная ошибка теряется, а сломанный клиент возвращается в пул. Нужен client.release(err) (#3).
  5. ROLLBACK после успешного COMMIT (ошибка в письме) даёт лишний запрос и предупреждение there is no transaction in progress.
  6. status <> 'paid' не сработает при status IS NULL (#2).
  7. Нет проверки metadata.order_id (#11), pool.on('error') и таймаутов (#12), нет обработки ошибок в /api/orders (#5).

Итоговый исправленный код

'use strict';

const express = require('express');
const { Pool } = require('pg');
const Stripe = require('stripe');
const nodemailer = require('nodemailer');

// ---------- Конфигурация и проверка окружения ----------
function requireEnv(name) {
  const value = process.env[name];
  if (!value) {
    console.error(`Missing required env variable: ${name}`);
    process.exit(1);
  }
  return value;
}

const DATABASE_URL = requireEnv('DATABASE_URL');
const STRIPE_SECRET_KEY = requireEnv('STRIPE_SECRET_KEY');
const STRIPE_WEBHOOK_SECRET = requireEnv('STRIPE_WEBHOOK_SECRET');
const PORT = Number(process.env.PORT) || 3000;

const stripe = Stripe(STRIPE_SECRET_KEY, {
  // Зафиксируйте версию API, под которую написан код:
  ...(process.env.STRIPE_API_VERSION ? { apiVersion: process.env.STRIPE_API_VERSION } : {}),
});

// ---------- База данных ----------
const pool = new Pool({
  connectionString: DATABASE_URL,
  max: Number(process.env.PG_POOL_MAX) || 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
  statement_timeout: 10_000,
  application_name: 'orders-api',
});

pool.on('error', (err) => {
  console.error('Unexpected error on idle pg client', err);
});

async function withTransaction(fn) {
  const client = await pool.connect();
  let brokenConnectionError;
  try {
    await client.query('BEGIN');
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (err) {
    try {
      await client.query('ROLLBACK');
    } catch (rollbackErr) {
      brokenConnectionError = rollbackErr;
      console.error('ROLLBACK failed, destroying client', rollbackErr);
    }
    throw err;
  } finally {
    client.release(brokenConnectionError);
  }
}

// ---------- Почта (Transactional Outbox) ----------
const transporter = nodemailer.createTransport({
  /* smtp config */
  pool: true,
});

function buildReceipt(order) {
  // NB: если amount хранится в минимальных единицах — отформатируйте перед выводом.
  return {
    to: order.customer_email,
    subject: 'Ваш заказ оплачен',
    text: `Заказ #${order.id} успешно оплачен на сумму ${order.amount}`,
  };
}

let outboxRunning = false;

async function processEmailOutbox() {
  if (outboxRunning) return;
  outboxRunning = true;
  try {
    const { rows } = await pool.query(
      `UPDATE email_outbox
          SET locked_until = now() + interval '2 minutes',
              attempts = attempts + 1
        WHERE id IN (
          SELECT id FROM email_outbox
           WHERE sent_at IS NULL
             AND attempts < 5
             AND (locked_until IS NULL OR locked_until < now())
           ORDER BY id
           LIMIT 10
           FOR UPDATE SKIP LOCKED)
        RETURNING id, payload`
    );

    for (const row of rows) {
      try {
        if (!row.payload || !row.payload.customer_email) {
          throw new Error('Outbox payload has no customer_email');
        }
        await transporter.sendMail(buildReceipt(row.payload));
        await pool.query(
          'UPDATE email_outbox SET sent_at = now(), locked_until = NULL, last_error = NULL WHERE id = $1',
          [row.id]
        );
      } catch (err) {
        console.error(`Failed to send outbox email id=${row.id}`, err);
        await pool.query(
          'UPDATE email_outbox SET locked_until = NULL, last_error = $2 WHERE id = $1',
          [row.id, String(err && err.message).slice(0, 500)]
        );
      }
    }
  } finally {
    outboxRunning = false;
  }
}

function kickOutbox() {
  processEmailOutbox().catch((err) => console.error('Outbox processing failed', err));
}

const outboxTimer = setInterval(kickOutbox, 5_000);

// ---------- Утилиты ----------
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

function parseOrderId(raw) {
  // Предполагается целочисленный id. Для uuid замените проверку.
  if (typeof raw !== 'string' || !/^[1-9]\d{0,17}$/.test(raw)) return null;
  return raw;
}

function parseIntParam(raw, { def, min, max }) {
  if (raw === undefined || raw === '') return def;
  if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null;
  const n = Number(raw);
  if (!Number.isSafeInteger(n) || n < min || n > max) return null;
  return n;
}

// ---------- Приложение ----------
const app = express();

// Вебхук регистрируется ДО express.json(): для подписи нужно сырое тело.
app.post(
  '/api/stripe-webhook',
  express.raw({ type: 'application/json', limit: '1mb' }),
  asyncHandler(async (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers['stripe-signature'],
        STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.warn('Stripe signature verification failed:', err.message);
      return res.status(400).send('Invalid signature');
    }

    if (event.type !== 'payment_intent.succeeded') {
      return res.sendStatus(200);
    }

    const paymentIntent = event.data.object;
    const orderId = parseOrderId(paymentIntent?.metadata?.order_id);
    if (orderId === null) {
      console.error('payment_intent.succeeded without valid metadata.order_id', {
        eventId: event.id,
        paymentIntentId: paymentIntent?.id,
      });
      return res.sendStatus(200); // ack: ретрай не исправит данные
    }

    const outcome = await withTransaction(async (client) => {
      const inserted = await client.query(
        `INSERT INTO processed_webhook_events (event_id, event_type)
         VALUES ($1, $2)
         ON CONFLICT (event_id) DO NOTHING
         RETURNING event_id`,
        [event.id, event.type]
      );
      if (inserted.rowCount === 0) return 'duplicate_event';

      // TODO(#13): сверить сумму/валюту и допустимый исходный статус под вашу схему:
      //   AND status = 'pending' AND amount = $2 AND currency = $3
      const updated = await client.query(
        `UPDATE orders
            SET status = 'paid'
          WHERE id = $1 AND status IS DISTINCT FROM 'paid'
          RETURNING id, amount, customer_email`,
        [orderId]
      );
      if (updated.rowCount === 0) return 'already_paid_or_missing';

      await client.query(
        `INSERT INTO email_outbox (order_id, kind, payload)
         VALUES ($1, 'receipt', $2)
         ON CONFLICT (order_id, kind) DO NOTHING`,
        [updated.rows[0].id, JSON.stringify(updated.rows[0])]
      );
      return 'paid';
    });

    if (outcome !== 'paid') {
      console.info(`Webhook ${event.id}: ${outcome} (order ${orderId})`);
    }

    res.sendStatus(200);       // быстрый ответ Stripe
    if (outcome === 'paid') kickOutbox(); // письмо — асинхронно, вне запроса
  })
);

app.use(express.json());

app.get(
  '/api/orders',
  // TODO(#14): добавить middleware аутентификации/авторизации
  asyncHandler(async (req, res) => {
    const limit = parseIntParam(req.query.limit, { def: 10, min: 1, max: 100 });
    if (limit === null) {
      return res.status(400).json({ error: 'limit must be an integer 1..100' });
    }

    // Keyset-режим: ?after_id=10&limit=10 (устойчив к удалениям, быстрый на глубине)
    if (req.query.after_id !== undefined) {
      const afterId = parseIntParam(req.query.after_id, { def: 0, min: 0, max: Number.MAX_SAFE_INTEGER });
      if (afterId === null) {
        return res.status(400).json({ error: 'after_id must be a non-negative integer' });
      }
      const { rows } = await pool.query(
        `SELECT id, status, amount, customer_email
           FROM orders
          WHERE id > $1
          ORDER BY id
          LIMIT $2`,
        [afterId, limit]
      );
      return res.json(rows);
    }

    const page = parseIntParam(req.query.page, { def: 1, min: 1, max: 100_000 });
    if (page === null) {
      return res.status(400).json({ error: 'page must be a positive integer' });
    }
    const offset = (page - 1) * limit;

    const { rows } = await pool.query(
      `SELECT id, status, amount, customer_email
         FROM orders
        ORDER BY id
        LIMIT $1 OFFSET $2`,
      [limit, offset]
    );
    res.json(rows);
  })
);

app.get(
  '/healthz',
  asyncHandler(async (req, res) => {
    await pool.query('SELECT 1');
    res.json({ ok: true });
  })
);

// Централизованный обработчик ошибок (последним)
app.use((err, req, res, next) => {
  console.error(`[${req.method} ${req.originalUrl}]`, err);
  if (res.headersSent) return next(err);
  res.status(500).json({ error: 'Internal Server Error' });
});

const server = app.listen(PORT, () => console.log(`Server started on port ${PORT}`));

// ---------- Graceful shutdown (Render шлёт SIGTERM при деплое) ----------
function shutdown(signal) {
  console.log(`${signal} received, shutting down`);
  clearInterval(outboxTimer);
  server.close(async () => {
    try {
      await pool.end();
      transporter.close();
    } finally {
      process.exit(0);
    }
  });
  setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

module.exports = { app, pool }; // для тестов

Миграция

CREATE TABLE IF NOT EXISTS processed_webhook_events (
  event_id     text PRIMARY KEY,
  event_type   text NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS email_outbox (
  id           bigserial PRIMARY KEY,
  order_id     bigint NOT NULL,
  kind         text   NOT NULL,
  payload      jsonb  NOT NULL,
  attempts     int    NOT NULL DEFAULT 0,
  locked_until timestamptz,
  last_error   text,
  sent_at      timestamptz,
  created_at   timestamptz NOT NULL DEFAULT now(),
  UNIQUE (order_id, kind)
);

CREATE INDEX IF NOT EXISTS email_outbox_pending_idx
  ON email_outbox (id) WHERE sent_at IS NULL;

-- Опционально: чистка старых записей идемпотентности (Stripe ретраит до ~3 суток)
-- DELETE FROM processed_webhook_events WHERE processed_at < now() - interval '30 days';

Если orders.id имеет тип uuid, замените bigint в email_outbox.order_id и регулярку в parseOrderId.


Список изменений

  1. Вебхук регистрируется до express.json() с express.raw(), подпись проверяется через constructEvent (#1).
  2. Двухуровневая идемпотентность: processed_webhook_events с ON CONFLICT плюс status IS DISTINCT FROM 'paid' и UNIQUE (order_id, kind) в outbox (#2).
  3. withTransaction: BEGIN/COMMIT/ROLLBACK, release() в finally, уничтожение сломанного клиента (#3, #4).
  4. Все запросы идут через await, RETURNING заменяет лишний SELECT (#4).
  5. asyncHandler и централизованный error-middleware для Express 4 (#5).
  6. Все SQL-запросы параметризованы (#6, #7).
  7. offset = (page - 1) * limit; строгая валидация page, limit, after_id; limit ≤ 100 (#8, #9).
  8. Transactional Outbox: письмо ставится в очередь атомарно со сменой статуса, отправляется асинхронно с ретраями и lease; transporter создаётся один раз с пулом (#10).
  9. Отсутствующий или некорректный order_id подтверждается 200 и логируется (#11).
  10. Пул: connectionTimeoutMillis, idleTimeoutMillis, statement_timeout, application_name, pool.on('error') (#12).
  11. TODO-проверка суммы, валюты и допустимого исходного статуса (#13); явный список столбцов вместо SELECT * (#14).
  12. PORT из env, проверка обязательных env, /healthz, graceful shutdown (#15).
  13. Опциональная keyset-пагинация ?after_id= со строгим > (#16).

Тесты (Jest + supertest, реальная тестовая БД)

const request = require('supertest');
const Stripe = require('stripe');
const { app, pool } = require('../server');

const stripe = Stripe('sk_test_dummy');
const secret = process.env.STRIPE_WEBHOOK_SECRET;

function signed(event) {
  const payload = JSON.stringify(event);
  const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
  return { payload, header };
}

const piEvent = (id, orderId) => ({
  id,
  type: 'payment_intent.succeeded',
  data: { object: { id: 'pi_test', metadata: { order_id: String(orderId) } } },
});

beforeEach(async () => {
  await pool.query('TRUNCATE processed_webhook_events, email_outbox');
  await pool.query('TRUNCATE orders RESTART IDENTITY');
  await pool.query(`
    INSERT INTO orders (status, amount, customer_email)
    SELECT 'pending', 1000, 'u' || g || '@example.com' FROM generate_series(1, 25) g`);
});

afterAll(() => pool.end());

test('rejects unsigned webhook', async () => {
  await request(app).post('/api/stripe-webhook')
    .set('Content-Type', 'application/json')
    .send(JSON.stringify(piEvent('evt_1', 1)))
    .expect(400);
});

test('same event delivered 5x concurrently → one update, one email', async () => {
  const { payload, header } = signed(piEvent('evt_dup', 3));
  const responses = await Promise.all(Array.from({ length: 5 }, () =>
    request(app).post('/api/stripe-webhook')
      .set('Content-Type', 'application/json')
      .set('Stripe-Signature', header)
      .send(payload)));
  responses.forEach((r) => expect(r.status).toBe(200));

  const outbox = await pool.query('SELECT count(*)::int AS n FROM email_outbox WHERE order_id = 3');
  expect(outbox.rows[0].n).toBe(1);
  const { rows } = await pool.query('SELECT status FROM orders WHERE id = 3');
  expect(rows[0].status).toBe('paid');
});

test('different events for the same order → still one email', async () => {
  for (const id of ['evt_a', 'evt_b']) {
    const { payload, header } = signed(piEvent(id, 4));
    await request(app).post('/api/stripe-webhook')
      .set('Content-Type', 'application/json').set('Stripe-Signature', header)
      .send(payload).expect(200);
  }
  const { rows } = await pool.query('SELECT count(*)::int AS n FROM email_outbox WHERE order_id = 4');
  expect(rows[0].n).toBe(1);
});

test('pagination: pages are disjoint and contiguous', async () => {
  const p1 = (await request(app).get('/api/orders?page=1&limit=10')).body.map((o) => o.id);
  const p2 = (await request(app).get('/api/orders?page=2&limit=10')).body.map((o) => o.id);
  expect(p1).toEqual([1,2,3,4,5,6,7,8,9,10]);
  expect(p2).toEqual([11,12,13,14,15,16,17,18,19,20]);
});

test.each(['abc', '-1', '0', '1.5', '101', '1;SELECT 1'])('invalid limit %s → 400', async (v) => {
  await request(app).get(`/api/orders?limit=${encodeURIComponent(v)}`).expect(400);
});

test('no pool leak after many webhook calls', async () => {
  for (let i = 0; i < 30; i++) {
    const { payload, header } = signed(piEvent(`evt_leak_${i}`, (i % 25) + 1));
    await request(app).post('/api/stripe-webhook')
      .set('Content-Type', 'application/json').set('Stripe-Signature', header)
      .send(payload).expect(200);
  }
  expect(pool.totalCount - pool.idleCount).toBe(0);
  expect(pool.waitingCount).toBe(0);
});

Мониторинг на staging:

  • Лог метрик пула каждые 30 с (totalCount, idleCount, waitingCount), алерт на waitingCount > 0 дольше 30 с.
  • Алерты на email_outbox: неотправленные дольше 10 минут и attempts >= 5.
  • В Stripe Dashboard → Webhooks: доля неуспешных доставок и время ответа эндпоинта.
  • Алерт на numbackends из pg_stat_database выше 80% от max_connections.

Что нельзя установить по коду (нужны данные)

  1. Пагинация «10–19». Присланный код даёт 11–20 и 21–30, а не 1–10 и 10–19. Нужны commit SHA задеплоенной версии на Render, фактический SQL из лога Postgres (log_min_duration_statement = 0 временно) и то, какие параметры реально шлёт клиент (Network в DevTools). Моя основная гипотеза — курсорная пагинация с >= в задеплоенной версии или на клиенте.
  2. UnhandledPromiseRejectionWarning на Node 20. Это формат предупреждения из Node ≤14 или из режима --unhandled-rejections=warn. В Node 20 по умолчанию процесс падает, и вывод выглядит иначе. Проверьте node -v в Render Shell или в build-логах, поле engines.node в package.json, переменную NODE_VERSION / файл .node-version и NODE_OPTIONS. Если на staging реально старый Node, поведение при ошибках там отличается от локального.
  3. too many clients already. Один процесс с этим кодом ограничен 10 соединениями, поэтому ошибка сервера означает, что суммарно соединений больше, чем max_connections. Нужны SHOW max_connections;, вывод запроса по pg_stat_activity из #12 в момент ошибки, число инстансов сервиса (и есть ли autoscaling), список других сервисов и воркеров на той же БД, а также используется ли cluster или PM2. Утечка в вебхуке (#3) реальна и исправлена, но сама по себе она даёт зависание запросов, а не эту серверную ошибку.
  4. Схема orders: тип id, единицы и валюта amount, перечень статусов. От этого зависят parseOrderId, тип email_outbox.order_id и проверка из #13.

Похожие промпты

Программирование и Разработка

Проектировщик REST API

Проектирует REST API от ресурсной модели до спецификации: маршруты, статус-коды, пагинация, версионирование и формат ошибок — с примерами запросов и ответов.

Программирование и Разработка

Ревью Next.js-кода: App Router и Server Components

Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».

Программирование и Разработка

SEO-аудит Next.js-приложения

Проверяет Next.js-приложение по SEO-чеклисту: метаданные и canonical, рендеринг для ботов, sitemap/robots, Core Web Vitals — и выдаёт план правок по приоритету.

Программирование и Разработка

Ревью backend-кода: архитектура и безопасность

Проводит ревью серверного кода: слои и границы модулей, работа с БД и транзакциями, обработка ошибок, инъекции и утечки данных — замечания ранжированы, правки «до/после».