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

Примеры тестов 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, 404 PAYER_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.jsgetAccountByTbId() возвращает синтетический 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.jspublishIntentStatus стабится; проверяется, что вызывается с правильным статусом.
  • channels/_p2p-saga.jsrunP2pSaga стабится, потому что 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) + direct transit→revenue на POST_fee,
  • Step 2 идемпотентность: pending_transfer_already_posted не падает, pending_transfer_expiredPendingExpiredError,
  • Детерминированные 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_CHARGEnfcTagId, 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'а просто не появляется. И наоборот: для нормальных пользователей — появляется (см. предыдущий тест).


Что должен знать автор нового теста

  1. Используйте thenable-моки для Drizzle, а не Promise.resolve(). Цепочка from().where().limit() ожидает chainable + awaitable объект (см. makeSelectOnce в confirm-handler.test.ts).
  2. Для каналов мокируйте только getTb() — расчёт сумм, флагов и UUID'ов проверяется на реальных аргументах createTransfers. БД и Redis на этом уровне не нужны.
  3. Для HTTP-роутов поднимайте buildApp() и подписывайте запросы HMAC по-настоящему (signRequest()-хелпер). Не отключайте HMAC через моки — это снижает покрытие.
  4. Симулируйте race через UPDATE … RETURNING [] (пустой массив) — это единственный надёжный способ воспроизвести гонку в Drizzle/Postgres-тестах без реальной БД.
  5. Канал ADMIN_TRANSFER (не «ADMIN») — operationType, который пишет в tx_history через tb_account_map lookup с userId=0. См. test/intent/settlement-writer.test.ts:113-131.
  6. Reset/clear моков в beforeEachvi.resetAllMocks() сбрасывает в том числе implementations из factory vi.mock(). Перепрописывайте их явно (см. confirm-handler.test.ts:151-178).

См. также