Перейти к содержанию

Тестовые паттерны Payment Manager

Набор практических паттернов для написания unit- и integration-тестов в Payment Manager: фикстуры DB и TigerBeetle, моки PSP-адаптеров, стандартный setup/teardown, HMAC-подпись запросов и работа с Fastify через app.inject(). Все примеры опираются на реально существующие helpers и mocks проекта.

Тестовый стек: Vitest (describe, it, beforeEach, vi.mock). Конфигурация — в vitest.config.ts. Запуск: npm test (unit) или INTEGRATION=1 npm test (integration).


1. Структура тест-помощников

test/
├── helpers/
│   ├── app.ts        — buildTestApp(), signRequest()
│   └── db.ts         — resetDb(), seedAuthCenterServiceKey(), seedMerchantAccount(), seedUserAccount()
├── mocks/
│   └── ipps-mock-server.ts  — Fastify-based IPPS HTTP-mock
├── integration/      — тесты, требующие реальный PostgreSQL + TigerBeetle (INTEGRATION=1)
└── intent/, ledger/, channels/, ...  — unit-тесты с `vi.mock`

Правило: в unit-тестах нельзя импортировать test/helpers/db.ts статически — он подтягивает реальное DB-соединение. Используйте динамический await import('../helpers/db.js') внутри beforeEach, защищённого describe.skipIf(!RUN) (см. §6).


2. Паттерн «фикстура DB»

Helper test/helpers/db.ts (см. src/shared/db.ts, src/shared/schema.ts) предоставляет минимальный набор сидов для integration-тестов.

2.1. resetDb()

Полная очистка всех pm.* таблиц с RESTART IDENTITY CASCADE. Вызывается в beforeEach:

import { resetDb } from '../helpers/db.js'

beforeEach(async () => {
  await resetDb()
})

Под капотом — один TRUNCATE со списком таблиц в обратном порядке зависимостей: tx_history → intent → fee_rule → payment_route → limit_rule → auth_policies → service_key → tb_account_map.

2.2. seedAuthCenterServiceKey()

Создаёт запись в pm.service_key для сервиса auth-center с разрешениями allowedOperationTypes: ['P2P_TRANSFER', 'INVOICE_PAYMENT'] и allowToTbAccountId: true. Параллельно добавляет payment_route для INVOICE_PAYMENT → MERCHANT_INVOICE. Использует onConflictDoUpdate / onConflictDoNothing — безопасен для повторных вызовов.

2.3. seedMerchantAccount({ userId }) и seedUserAccount({ userId, currency? })

Создают запись в tb_account_map. Возвращают { userId, tbAccountId, accountName, currency? }. TigerBeetle ID детерминирован:

Тип Шаблон ID Имя
MERCHANT_WALLET 10000000-0000-0000-0000-{userId(12)} merchant.{userId}.THB
USER_WALLET 20000000-0000-0000-0000-{userId(12)} user.{userId}.{currency}

Эти helpers пишут только в PostgreSQL (pm.tb_account_map). Реальный аккаунт в TigerBeetle создаётся отдельно через seedAccounts() из src/ledger/accounts.ts (см. §3).

Открытый вопрос: helpers для сидинга fee_rule, limit_rule, auth_policies пока отсутствуют — добавлять inline через db.insert(...) или расширять test/helpers/db.ts по мере появления соответствующих тестов.


3. Паттерн «фикстура TB»

В проекте нет обёртки типа setupTestLedger(). Применяются два разных подхода в зависимости от типа теста:

3.1. Unit-тесты — мок getTb()

Модуль src/shared/tb.ts экспортирует getTb(). В unit-тестах он мокается целиком:

vi.mock('../../src/shared/tb', () => ({ getTb: vi.fn() }))
import { getTb } from '../../src/shared/tb'

Дальше тест-специфичный мок возвращает нужный результат createAccounts / createTransfers / lookupAccounts. Статусы импортируются из реального SDK:

import { CreateAccountStatus, CreateTransferStatus, TransferFlags } from 'tigerbeetle-node'

Примеры: test/ledger/accounts.test.ts, test/ledger/transfers.test.ts, test/channels/internal-p2p.test.ts.

3.2. Integration-тесты — реальный TigerBeetle

test/integration/invoice-flow.test.ts опирается на реальный TB-кластер, поднятый через docker-compose. Запускается только при INTEGRATION=1:

INTEGRATION=1 INVOICE_QR_SECRET=... DATABASE_URL=... TB_ADDRESS=... npm test

buildApp() сам инициализирует TB-клиент по TB_ADDRESS. Тестовый ledger не сбрасывается между тестами — изоляция обеспечивается уникальными userId (100, 101, 103, 200, 201, ...). Эту дисциплину придётся поддерживать вручную.

Открытый вопрос: resetDb() чистит только PostgreSQL; накопление аккаунтов/трансферов в TB между прогонами тестов остаётся открытой темой. Для CI обычно поднимают одноразовый контейнер.


4. Паттерн «мок PSP»

Моки внешних PSP лежат в test/mocks/ (а не в test/helpers/).

4.1. IppsMockServertest/mocks/ipps-mock-server.ts

Полноценный Fastify-сервер, имитирующий REST IPPS. Поддерживает эндпоинты:

  • register-wallet-user
  • deactivate-wallet-user
  • wallet-transfer/query
  • wallet-transfer/confirm
  • wallet-transfer/inquiry
  • partners/:id/balance

API:

const ipps = new IppsMockServer()
await ipps.start()                                          // baseUrl = http://127.0.0.1:<port>
ipps.respondWith('wallet-transfer/confirm', 200, { ... })   // fix-response
ipps.on('wallet-transfer/inquiry', async req => ({          // динамика
  status: 200, body: { ... }, delayMs: 1500,
}))
const calls = ipps.calls()                                  // лог вызовов с body + apiKey
ipps.reset()
await ipps.stop()

В тестах канала IPPS (test/channels/ipps-transfer.test.ts) baseUrl мок-сервера передаётся в конфиг канала вместо реального IPPS endpoint.

Открытый вопрос: для канала QP (Phase 2B) мок ещё не реализован.


5. Паттерн «beforeAll / beforeEach»

5.1. Unit-тест intent-хэндлера

Шаблон (см. test/intent/confirm-handler.test.ts): жёстко замокать все соседние модули, чтобы тест был полностью offline.

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { buildApp } from '../../src/server.js'

vi.mock('../../src/shared/db.js',          () => ({ db: { select: vi.fn(), insert: vi.fn(), update: vi.fn() } }))
vi.mock('../../src/shared/tb.js',          () => ({ getTb: vi.fn() }))
vi.mock('../../src/shared/redis.js',       () => ({
  getRedis:   vi.fn().mockReturnValue({ publish: vi.fn().mockResolvedValue(1) }),
  closeRedis: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../src/intent/router.js',           () => ({ resolveChannel: vi.fn() }))
vi.mock('../../src/intent/step-registry.js',    () => ({
  getChannel: vi.fn(), registerChannel: vi.fn(), clearRegistry: vi.fn(),
  isTwoPhase: vi.fn().mockReturnValue(true),
}))
vi.mock('../../src/ledger/accounts.js',         () => ({
  getAccountByName: vi.fn(), getAccountByTbId: vi.fn(),
  seedAccounts: vi.fn(), verifySystemAccounts: vi.fn(),
}))
vi.mock('../../src/intent/startup-reconciler.js',  () => ({ reconcile: vi.fn() }))
vi.mock('../../src/intent/intent-events.js',       () => ({
  writeIntentEvent:    vi.fn().mockResolvedValue(undefined),
  publishIntentStatus: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../src/intent/settlement-writer.js',   () => ({ writeSettlement: vi.fn().mockResolvedValue(undefined) }))
vi.mock('../../src/intent/notify.js',              () => ({ publishPaymentNotification: vi.fn().mockResolvedValue(undefined) }))
vi.mock('../../src/limits/check-limits.js',        () => ({ checkLimits: vi.fn().mockResolvedValue(undefined) }))
vi.mock('../../src/rule-engine/fee-calculator.js', () => ({
  calculateFees: vi.fn().mockResolvedValue({ splits: [], totalFee: 0n }),
}))
vi.mock('../../src/intent/context-builder.js',     () => ({
  buildIntentContext: vi.fn().mockResolvedValue({
    intent: {}, channel: {}, fromAccountId: 100n, toAccountId: 200n,
    transitId: 300n, tbTransferIds: [], feeSplits: [],
  }),
}))

beforeEach(() => {
  vi.clearAllMocks()
})

Замоканный db.select() обычно строится через chain-mock (см. makeSelectOnce(rows) в том же файле): from().where().limit() возвращают массив строк.

5.2. Integration-тест с describe.skipIf

Чтобы один файл содержал и unit-, и integration-блок, используется describe.skipIf(!RUN) и динамические импорты внутри beforeEach (см. test/integration/invoice-flow.test.ts):

const RUN = process.env.INTEGRATION === '1'

describe.skipIf(!RUN)('Invoice flow — integration', () => {
  let buildTestApp:  typeof import('../helpers/app.js').buildTestApp
  let signReqHelper: typeof import('../helpers/app.js').signRequest
  let resetDb:       typeof import('../helpers/db.js').resetDb

  beforeEach(async () => {
    ({ buildTestApp, signRequest: signReqHelper } = await import('../helpers/app.js'))
    ;({ resetDb } = await import('../helpers/db.js'))
    await resetDb()
    await seedAuthCenterServiceKey()
  })
})

6. Паттерн «integration test через app.inject()»

Fastify предоставляет встроенный inject — HTTP-сервер не поднимается, обработчик вызывается напрямую. Это и быстрее, и стабильнее, чем http.request.

import { buildTestApp, signRequest } from '../helpers/app.js'

const app = await buildTestApp()      // buildApp({ logger: false })

const body = {
  operationType:  'INVOICE_PAYMENT',
  userId:         100,
  idempotencyKey: crypto.randomUUID(),
  amount:         '15000',
  currency:       'THB',
  toTbAccountId:  merchant.tbAccountId,
}

const res = await app.inject({
  method:  'POST',
  url:     '/intents',
  headers: signRequest(body, 100),
  payload: body,
})

expect(res.statusCode).toBe(201)
const parsed = JSON.parse(res.body)

Особенности:

  • payload — это объект или строка; Fastify сам не сериализует, если передан объект. signRequest сериализует тем же JSON.stringify(body), что и Fastify, чтобы хэш совпал.
  • Для URL с path-params подпись считается по полному пути (включая uuid): signRequest(body, userId, 'POST',/intents/${id}/confirm).
  • Конкурентные сценарии пишутся через Promise.all([app.inject(...), app.inject(...)]) — каждый inject создаёт независимый контекст запроса (см. тест «concurrent confirm: one wins, one gets 409»).

7. HMAC-подпись в тестах

Все запросы в PM подписываются заголовками X-Service-Id, X-Timestamp, X-Signature (см. docs/dev/integrations/hmac.md). В тестах для этого есть helper signRequest из test/helpers/app.ts:

export function signRequest(
  body:   unknown,
  userId  = 1,
  method  = 'POST',
  url     = '/intents',
): Record<string, string>

Алгоритм точно повторяет продакшен-валидатор:

const SERVICE_SECRET = 'test-secret-auth-center-xx'        // фиксирован для тестов

const bodyStr = JSON.stringify(body)
const ts      = Math.floor(Date.now() / 1000).toString()
const hash    = createHash('sha256').update(bodyStr).digest('hex')
const msg     = `${ts}\n${method}\n${url}\n${hash}`        // ровно такой message-format
const sig     = createHmac('sha256', SERVICE_SECRET).update(msg).digest('hex')

Возвращает заголовки:

Header Значение
x-service-id auth-center
x-timestamp unix-секунды
x-signature hex SHA-256 HMAC
x-user-id String(userId)
content-type application/json

Важно: SERVICE_SECRET = 'test-secret-auth-center-xx' захардкожен и в helper, и в unit-тестах (см. test/intent/confirm-handler.test.ts). В реальной среде секрет приходит из env (HMAC_SECRETS). Если тест меняет конфиг — синхронизируйте оба места.

Для unit-тестов, где test/helpers/app.ts нельзя использовать (он подтягивает реальный buildApp), копируется локальная мини-версия signRequest(method, path, body) (см. строки 71–83 в test/intent/confirm-handler.test.ts). Это сознательное дублирование ради изоляции.


8. Чек-лист при написании нового теста

  • Тип теста: unitvi.mock) или integration (INTEGRATION=1 + реальный PG/TB)?
  • Для integration: используется describe.skipIf(!RUN) и динамические импорты в beforeEach.
  • DB сбрасывается через resetDb(); сиды через seedAuthCenterServiceKey(), seedMerchantAccount(), seedUserAccount().
  • HTTP-вызовы — через app.inject(...), headers — через signRequest(...).
  • Подпись считается по полному URL с path-params, не по шаблону.
  • Уникальный userId для каждого integration-теста (изоляция TB-аккаунтов).
  • vi.clearAllMocks() в beforeEach для unit-тестов.
  • Канал — ADMIN, не ADMIN_TRANSFER.

См. также