Примеры тестов Payment Manager¶
Разбор пяти реальных тестов из директории test/: что они проверяют, какие зависимости мокируются и почему именно так. Все ссылки — на актуальные файлы в репозитории. Используйте этот документ как справочник, когда нужно написать новый тест по аналогии с уже существующими.
1. confirm-handler.test.ts — HTTP-роут подтверждения интента¶
Файл: test/intent/confirm-handler.test.ts
Что проверяет¶
Это интеграционный тест уровня HTTP: поднимается реальный Fastify через buildApp({ logger: false }), и через app.inject() дёргается POST /intents/:id/confirm. Покрывается полный матрица сценариев MERCHANT_INVOICE confirm:
- happy path
CREATED → VALIDATED → AUTHORIZED → SETTLED, - идемпотентный реплей (тот же
idempotencyKey+ тот же payer на ужеSETTLEDинтенте), - 409
ALREADY_PROCESSED(race: UPDATE вернул 0 строк, статус уже неCREATED), - 409
EXPIRED(UPDATE 0 строк +expiresAtв прошлом), - 409
VERSION_MISMATCH(оптимистичная блокировка поversion), - 404
INTENT_NOT_FOUND, 404PAYER_ACCOUNT_NOT_FOUND, - 400
PAYER_ACCOUNT_USER_MISMATCH(TB account принадлежит другому пользователю).
Что мокируется и почему¶
Перед import { buildApp } через vi.mock() подменяются все внешние эффекты:
shared/db.js— Drizzle-клиент: тест собирает thenable-цепочки вручную, чтобы не поднимать Postgres.shared/tb.js— TigerBeetle SDK: ни одного реального transfer'а в тесте быть не должно.intent/step-registry.js— реестр каналов: подмена нужна, чтобы вернуть синтетическийmockChannelсredeem(), моментально возвращающимsettled.ledger/accounts.js—getAccountByTbId()возвращает синтетический payer, без обращения к БД.intent/context-builder.js,rule-engine/fee-calculator.js,limits/check-limits.js— отключаются: тест проверяет именно HTTP-роут и его state-машину, а не их внутреннюю логику.shared/redis.js,intent/notify.js,intent/intent-events.js,intent/settlement-writer.js— все side-effect'ы (Redis publish, settlement вtx_history) превращаются вnoop-резолвы.
Ключевой фрагмент — подпись HMAC¶
test/intent/confirm-handler.test.ts:71-83
function signRequest(method: string, path: string, body: string): Record<string, string> {
const ts = Math.floor(Date.now() / 1000).toString()
const hash = createHash('sha256').update(body).digest('hex')
const msg = `${ts}\n${method}\n${path}\n${hash}`
const sig = createHmac('sha256', SERVICE_SECRET).update(msg).digest('hex')
return {
'x-service-id': 'auth-center',
'x-timestamp': ts,
'x-signature': sig,
'x-user-id': '1',
'content-type': 'application/json',
}
}
Почему именно так: PM требует HMAC на каждый входящий запрос (см. src/hmac/*). Если использовать стабовый middleware или отключать HMAC, тест перестанет ловить регрессии формата канонической строки. Поэтому подпись считается «по-настоящему» на тот же SERVICE_SECRET, что сидит в env, поднятом в vitest.setup.ts.
Ключевой фрагмент — thenable DB-mock¶
test/intent/confirm-handler.test.ts:87-104
function makeSelectOnce(rows: unknown[]) {
const chain: any = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue(rows),
then: (resolve: any, reject?: any) => Promise.resolve(rows).then(resolve, reject),
catch: (reject: any) => Promise.resolve(rows).catch(reject),
finally:(fn: any) => Promise.resolve(rows).finally(fn),
}
return chain
}
Почему именно так: Drizzle-запросы вида await db.select().from(t).where(eq).limit(1) — это thenable-цепочка, а не Promise. Если вернуть просто Promise.resolve(rows), билдер сломается на промежуточных .from()/.where()/.limit(). Поэтому объект и chainable (через mockReturnThis), и awaitable (через ручной then).
2. evaluate-policy.test.ts — matcher conditions для step-up политик¶
Файл: test/limits/evaluate-policy.test.ts
Что проверяет¶
Юнит-тест чистой функции evaluatePolicy(): какие condition keys поддерживаются в JSONB condition колонке auth_policies, и как они комбинируются с приоритетами.
Покрытие condition keys:
amount_gte— пороговое значение суммы (PIN при больших amount),daily_cumulative_gte— суточный накопитель (KYC_UPLIFT при превышении),new_payee— первый платёж этому мерчанту,currency— фильтр по валюте,channel— фильтр по каналу платежа (INTERNAL_P2P,IPPS_TRANSFER,MERCHANT_INVOICE).
Покрытие приоритетов: priority 15 побеждает priority 20 (меньше — выше; см. реальный код evaluate-policy.ts).
Что мокируется¶
Единственная зависимость — shared/db.js. Контекст для матчера (dailyCumulative, isNewPayee) собирается через db.select(), поэтому каждый вызов в evaluatePolicy() соответствует одному mockReturnValueOnce(makeDbChain(...)). Порядок строго фиксирован:
1. daily cumulative sum,
2. isNewPayee (только если merchantId передан),
3. список политик с orderBy(priority).
Ключевой фрагмент — проверка приоритетов¶
test/limits/evaluate-policy.test.ts:82-94
it('returns PIN for new_payee matching policy (priority 15 wins over priority 20)', async () => {
dbMock.select
.mockReturnValueOnce(makeDbChain([{ sumUsed: '0' }])) // daily cumulative
.mockReturnValueOnce(makeDbChain([{ cnt: '0' }])) // isNewPayee check
.mockReturnValueOnce(makeDbChain([
{ id: 2, scope: 'global', condition: { new_payee: true }, requiredStepUp: 'PIN', reasonCode: 'new_payee', priority: 15, active: true },
{ id: 3, scope: 'global', condition: { amount_gte: 10_000 }, requiredStepUp: 'BIOMETRIC', reasonCode: 'large', priority: 20, active: true },
]))
const result = await evaluatePolicy({ ...baseReq, merchantId: 99 })
expect(result.required).toBe('PIN')
expect(result.reasonCode).toBe('new_payee')
})
Почему именно так: обе политики совпадают по condition (сумма 5 000 ≥ 10 000? нет, но new_payee=true сработал). Тест гарантирует, что evaluatePolicy() берёт первую по приоритету, а не первую совпавшую в массиве. Если бы код проходил массив сверху вниз и возвращал первое match — тест бы упал.
Ключевой фрагмент — channel condition¶
test/limits/evaluate-policy.test.ts:143-153
it('matches channel condition when channels match', async () => {
dbMock.select
.mockReturnValueOnce(makeDbChain([{ sumUsed: '0' }]))
.mockReturnValueOnce(makeDbChain([
{ id: 12, scope: 'global', condition: { channel: 'INTERNAL_P2P' }, requiredStepUp: 'PIN', reasonCode: 'internal_p2p', priority: 10, active: true },
]))
const result = await evaluatePolicy({ ...baseReq, channel: 'INTERNAL_P2P' })
expect(result.required).toBe('PIN')
expect(result.reasonCode).toBe('internal_p2p')
})
Почему именно так: это контракт между Auth Center и PM — Auth Center может настроить политику, которая требует PIN для всех INTERNAL_P2P независимо от суммы (например, для P2P с непроверенным получателем). Тест фиксирует, что channel действительно используется матчером.
3. merchant-invoice.test.ts — канал инвойса (reserve/cancel/expire)¶
Файл: test/channels/merchant-invoice.test.ts
Что проверяет¶
Юнит-тест канала MERCHANT_INVOICE (двухфазного: reserve в CREATED, redeem в момент confirm от покупателя). Фокус — переходы статусов и сайд-эффекты при reserve(), cancel(), expire().
Сценарии:
kindиname(контракт двухфазного канала),reserve()возвращает 16-байтовыйqrSignatureи обновляетdb,cancel()пишет вdbи публикуетCANCELEDв Redis (publishIntentStatus),cancel()бросаетCANNOT_CANCELпри гонке (UPDATE 0 строк),expire()no-op, если интент уже неCREATED(UPDATE 0 строк → нет публикации).
Что мокируется¶
shared/db.js— Drizzle-цепочкаupdate().set().where().returning()собирается вручную, чтобы контролировать, сколько строк «обновилось». Это критично для race-сценариев.intent/intent-events.js—publishIntentStatusстабится; проверяется, что вызывается с правильным статусом.channels/_p2p-saga.js—runP2pSagaстабится, потому чтоMERCHANT_INVOICE.redeem()под капотом вызывает общую P2P-сагу.INVOICE_QR_SECRETустанавливается вbeforeAll, потому что подпись QR-токена не должна полагаться на test-env defaults.
Ключевой фрагмент — race на cancel¶
test/channels/merchant-invoice.test.ts:103-115
it('cancel() throws CANNOT_CANCEL when update returns 0 rows', async () => {
// Симулируем что intent уже не CREATED (гонка)
const whereMock = vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([]),
})
const setMock = vi.fn().mockReturnValue({ where: whereMock })
dbMock.update.mockReturnValue({ set: setMock })
const intent = makeIntent({ status: 'SETTLED' })
await expect(
merchantInvoiceChannel.cancel({ intent, reason: 'x', actorUserId: 1 }),
).rejects.toThrow(/CANNOT_CANCEL/)
})
Почему именно так: в реальном PM cancel() делает UPDATE intents SET status='CANCELED' WHERE id=? AND status='CREATED' RETURNING *. Если за время между чтением и записью кто-то другой уже сделал redeem или expire, RETURNING отдаст пустой массив. Это единственный надёжный сигнал гонки в Postgres-транзакции. Тест эмулирует именно этот сценарий (returning → []) и проверяет, что код реагирует исключением CANNOT_CANCEL, а не молчаливо возвращает успех.
Ключевой фрагмент — expire идемпотентен¶
test/channels/merchant-invoice.test.ts:117-127
it('expire() no-op when update returns 0 rows', async () => {
const whereMock = vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([]),
})
const setMock = vi.fn().mockReturnValue({ where: whereMock })
dbMock.update.mockReturnValue({ set: setMock })
const intent = makeIntent({ status: 'CANCELED' })
await merchantInvoiceChannel.expire({ intent })
expect(publishMock).not.toHaveBeenCalled()
})
Почему именно так: expire() вызывается фоновым воркером по таймауту. К моменту обработки интент может уже быть CANCELED (покупатель сам отменил) или SETTLED (вошёл в гонку с confirm). В обоих случаях UPDATE не должен ничего изменить, и никаких событий публиковаться не должно. Тест фиксирует это явно: publishMock не вызывался.
4. internal-p2p.test.ts — двухфазный P2P-канал и саговая логика¶
Файл: test/channels/internal-p2p.test.ts
Что проверяет¶
После извлечения общей P2P-саги в src/channels/_p2p-saga.ts основные регрессии ловятся именно здесь. Тест работает на уровне internalP2PChannel.steps[0] (authorize) и steps[1] (settle) — то есть напрямую проверяет вызовы TigerBeetle через мокированный getTb().
Сценарии:
- Step 1 без fee — 2 PENDING transfer'а (user→transit, transit→recip),
LINKEDна первом,timeout=300, - Step 1 с PRE fee — 3 transfer'а (
user→transitна total = net+PRE,transit→recipна net,transit→fee_accountна PRE-fee), последний безLINKED, - Step 1 с двумя PRE splits → 4 transfer'а, все кроме последнего с
LINKED, - Step 1 с POST fee → 2 transfer'а (POST не идёт в PENDING-batch),
- Step 2 без fee → 2 POST_PENDING с явными суммами (
amount=10000n, не нулевые), - Step 2 с POST fee → partial post на
transit→recip(net − POST_fee) + directtransit→revenueна POST_fee, - Step 2 идемпотентность:
pending_transfer_already_postedне падает,pending_transfer_expired→PendingExpiredError, - Детерминированные
tbTransferIdsчерезuuidv5TbTransfer(intentId, index).
Что мокируется¶
Только shared/tb.js. Это принципиально: вся логика расчёта сумм, флагов LINKED/PENDING/POST_PENDING_TRANSFER, генерации UUID — настоящая, проверяется на реальных аргументах вызова createTransfers. БД здесь не нужна, потому что канал на этом уровне не пишет в Postgres (это делает _p2p-saga снаружи).
Ключевой фрагмент — LINKED flag для PRE-fee цепочки¶
test/channels/internal-p2p.test.ts:194-212
it('PRE fee → 3 transfers, user hold = net + PRE fee', async () => {
mockTb.createTransfers.mockResolvedValue([])
const ctx = makeCtx({
feeSplits: [{ accountName: 'system.revenue.THB', tbAccountId: 400n, amount: 150n, timing: 'PRE' }],
})
await internalP2PChannel.steps[0](ctx)
const [transfers] = mockTb.createTransfers.mock.calls[0]
expect(transfers).toHaveLength(3)
expect(transfers[0].amount).toBe(10150n) // total = net + PRE
expect(transfers[0].credit_account_id).toBe(300n) // → transit
expect(transfers[1].amount).toBe(10000n) // net to recip (PRE not deducted from recip)
expect(transfers[1].flags & TransferFlags.linked).toBeTruthy() // linked to fee transfer
expect(transfers[2].debit_account_id).toBe(300n) // transit → fee
expect(transfers[2].credit_account_id).toBe(400n) // revenue
expect(transfers[2].amount).toBe(150n)
expect(transfers[2].flags & TransferFlags.linked).toBeFalsy() // last — no linked
})
Почему именно так: TigerBeetle гарантирует атомарность batch'а только при правильной расстановке LINKED — все, кроме последнего. Это контракт TB (см. Linked Events). Если поставить LINKED на последнем — TB вернёт linked_event_chain_open, и весь batch упадёт. Тест проверяет именно это: «не более N−1 LINKED, и они стоят на правильных позициях».
Дополнительно фиксируется ключевой инвариант: PRE fee удерживается с плательщика, а не вычитается из получателя. transfers[0].amount = 10150 (плательщик отдаёт total), transfers[1].amount = 10000 (получатель получает полный net). Если бы кто-то «оптимизировал» в одну transfer-цепочку, эта проверка упала бы.
Ключевой фрагмент — детерминированные UUID¶
test/channels/internal-p2p.test.ts:86-95
it('populates ctx.tbTransferIds with 2 deterministic UUIDs', async () => {
mockTb.createTransfers.mockResolvedValue([])
const ctx = makeCtx()
await internalP2PChannel.steps[0](ctx)
expect(ctx.tbTransferIds).toHaveLength(2)
expect(ctx.tbTransferIds[0]).toBe(uuidv5TbTransfer(INTENT_ID, 0))
expect(ctx.tbTransferIds[1]).toBe(uuidv5TbTransfer(INTENT_ID, 1))
})
Почему именно так: TB IDs детерминированы — это правило архитектуры (см. CLAUDE.md: «TB account IDs детерминированы — верифицируемы без доп. state»). Тест ловит регрессию, если кто-то заменит UUIDv5 на UUIDv4 или поменяет порядок аргументов — uuidv5TbTransfer(intentId, index). Без этого свойства невозможна reconciliation: PM не сможет восстановить, какой именно TB transfer соответствует какой fee позиции.
5. settlement-writer.test.ts — проекция в tx_history¶
Файл: test/intent/settlement-writer.test.ts
Что проверяет¶
Юнит-тест функции writeSettlement(db, intent, feeSplits), которая после успешного settle'а проецирует движения денег в денормализованную таблицу tx_history (по строке на пользователя, не на TB transfer).
Сценарии:
- DEBIT для sender + CREDIT для recipient без комиссии (две строки, суммы совпадают с
intent.amount), - PRE fee →
DEBIT.feeAmount = preTotalFee,CREDIT.amount = net(получатель видит полную сумму), - POST fee →
CREDIT.amount = net − postTotalFee,CREDIT.feeAmount = postTotalFee(комиссия списана из суммы получателя), ADMIN_TRANSFERсuserId=0— DEBIT.userId резолвится изtb_account_map, а не изrec.userId,- Recipient lookup fallback из
tb_account_map.userId, когдаmetadata.recipientUserIdотсутствует, - Fee splits → дополнительные CREDIT-строки в
tx_historyсuserId=0(system revenue), - Проброс
fromName/toName/commentна DEBIT и CREDIT. - Проекция атрибутов через operation registry (
NFC_CHARGE→nfcTagId,lat,lon,businessCategoryCode).
Что мокируется¶
Db— собирается черезmakeMockDb()фабрику, которая мокаетinsert().values().onConflictDoUpdate({ conflictDoUpdate })иselect().from().where().limit(). Внутри есть тонкий контроль порядка вызовов: первыйselect.limit()обслуживает sender lookup (дляuserId=0), второй — recipient lookup.- Operation registry для NFC test'ов сбрасывается через
clearOperationRegistry()и регистрируется вручную черезregisterOperationType(nfcCharge).
Ключевой фрагмент — fallback на tb_account_map¶
test/intent/settlement-writer.test.ts:143-160
it('falls back to tb_account_map.user_id lookup when metadata.recipientUserId is absent', async () => {
// Auth Center QR/W2W flow: caller passes toTbAccountId, handler resolves toAccountName,
// but recipientUserId never lands in metadata. writeSettlement must look it up so the
// recipient sees the incoming tx in their history.
const { db, valsFn, selectFn } = makeMockDb({ recipientUserIdFromLookup: 4 })
await writeSettlement(db, makeRecord({
userId: 5,
toAccountName: 'user.4.THB',
metadata: { source: 'mobile_app' } as any, // no recipientUserId
}), [])
expect(selectFn).toHaveBeenCalledOnce()
const rows = valsFn.mock.calls[0][0] as any[]
expect(rows).toHaveLength(2)
const credit = rows.find((r: any) => r.direction === 'CREDIT')
expect(credit.userId).toBe(4)
expect(credit.accountName).toBe('user.4.THB')
})
Почему именно так: есть реальный класс багов — Auth Center передаёт получателя как toTbAccountId, PM резолвит его в toAccountName, но metadata.recipientUserId остаётся пустым. Без fallback'а в tb_account_map.userId получатель не увидит входящий перевод в истории. Тест защищает от регрессии: должно быть 2 строки (DEBIT + CREDIT) и ровно 1 SELECT в tb_account_map.
Ключевой фрагмент — system-аккаунт без recipient¶
test/intent/settlement-writer.test.ts:162-174
it('omits recipient row when toAccountName is a system account (tb_account_map.user_id IS NULL)', async () => {
// Settling to system.nostro.ipps.THB or similar — userId column is NULL for system
// accounts. The lookup returns a row but with userId: null → no recipient CREDIT.
const { db, valsFn } = makeMockDb({ recipientUserIdFromLookup: null })
await writeSettlement(db, makeRecord({
toAccountName: 'system.nostro.ipps.THB',
metadata: {} as any,
}), [])
const rows = valsFn.mock.calls[0][0] as any[]
expect(rows).toHaveLength(1)
expect(rows[0].direction).toBe('DEBIT')
})
Почему именно так: system-аккаунты (system.nostro.*, system.revenue.*) не имеют владельца-пользователя — tb_account_map.userId IS NULL. Если бы код наивно вставлял CREDIT с userId=null, упал бы NOT NULL constraint на tx_history.user_id. Тест гарантирует: при NULL'е recipient'а просто не появляется. И наоборот: для нормальных пользователей — появляется (см. предыдущий тест).
Что должен знать автор нового теста¶
- Используйте thenable-моки для Drizzle, а не
Promise.resolve(). Цепочкаfrom().where().limit()ожидаетchainable + awaitableобъект (см.makeSelectOnceвconfirm-handler.test.ts). - Для каналов мокируйте только
getTb()— расчёт сумм, флагов и UUID'ов проверяется на реальных аргументахcreateTransfers. БД и Redis на этом уровне не нужны. - Для HTTP-роутов поднимайте
buildApp()и подписывайте запросы HMAC по-настоящему (signRequest()-хелпер). Не отключайте HMAC через моки — это снижает покрытие. - Симулируйте race через
UPDATE … RETURNING [](пустой массив) — это единственный надёжный способ воспроизвести гонку в Drizzle/Postgres-тестах без реальной БД. - Канал
ADMIN_TRANSFER(не «ADMIN») — operationType, который пишет вtx_historyчерезtb_account_maplookup сuserId=0. См.test/intent/settlement-writer.test.ts:113-131. - Reset/clear моков в
beforeEach—vi.resetAllMocks()сбрасывает в том числе implementations из factoryvi.mock(). Перепрописывайте их явно (см.confirm-handler.test.ts:151-178).
См. также¶
docs/dev/testing/overview.md— Vitest конфигурация и общие хелперы.PASSPORT.md— канонический контракт PM (DTOs, parsers, операции).docs/AUTH-POLICIES.md— таблица condition keys дляevaluatePolicy.