Тестовые паттерны 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:
Под капотом — один 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:
Примеры: 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:
buildApp() сам инициализирует TB-клиент по TB_ADDRESS. Тестовый ledger не сбрасывается между тестами — изоляция обеспечивается уникальными userId (100, 101, 103, 200, 201, ...). Эту дисциплину придётся поддерживать вручную.
Открытый вопрос:
resetDb()чистит только PostgreSQL; накопление аккаунтов/трансферов в TB между прогонами тестов остаётся открытой темой. Для CI обычно поднимают одноразовый контейнер.
4. Паттерн «мок PSP»¶
Моки внешних PSP лежат в test/mocks/ (а не в test/helpers/).
4.1. IppsMockServer — test/mocks/ipps-mock-server.ts¶
Полноценный Fastify-сервер, имитирующий REST IPPS. Поддерживает эндпоинты:
register-wallet-userdeactivate-wallet-userwallet-transfer/querywallet-transfer/confirmwallet-transfer/inquirypartners/: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. Чек-лист при написании нового теста¶
- Тип теста: unit (с
vi.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.
См. также¶
test/helpers/app.ts—buildTestApp,signRequesttest/helpers/db.ts—resetDb,seedAuthCenterServiceKey,seedMerchantAccount,seedUserAccounttest/mocks/ipps-mock-server.ts—IppsMockServertest/integration/invoice-flow.test.ts— образцовый integration-тестtest/intent/confirm-handler.test.ts— образцовый unit-тест intent-хэндлераdocs/dev/api/auth.md— детали HMAC-протокола