Files
moeka-project/server/apps/api/src/services/domain/billing/tests/billing-service.test.ts
T
RainbowBirdandautofix-ci[bot] ab5e43ae0c test(server): use ioredis-mock for Redis behavior (#2294)
## Summary

- Replace Redis command fakes with `ioredis-mock` in API tests.
- Run the production Lua scripts through `EVAL`.
- Keep Redis behavior tests on the same command and Pub/Sub
implementation used by production code.

## Stack

- This PR is the base for #2289.
- It replaces #2291 as the merge-to-`main` unit. #2291 merged into the
old
  ConfigKV branch before the stack could be reordered.

## Tests

- `pnpm install --frozen-lockfile --offline --ignore-scripts`
- `pnpm exec vitest run <6 changed API test files>` (73 tests passed)
- `pnpm exec eslint <7 changed API TypeScript files>`
- `git diff --check`

## Visual changes

None. This PR changes test infrastructure only.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
  * Added isolated in-memory Redis support for automated testing.
* Updated billing, Stripe, flux, concurrency, and user-deletion tests to
use a shared Redis test setup.
* Improved verification of Redis operations while preserving existing
test coverage and expected outcomes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: RainbowBird <rbxin2003@outlook.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-15 21:43:30 +08:00

418 lines
16 KiB
TypeScript

import type { Database } from '../../../../libs/db'
import type { createConfigKVService } from '../../../adapters/config-kv'
import { and, eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../../../libs/mock-db'
import { createTestRedis } from '../../../../libs/tests/redis'
import { userFluxRedisKey } from '../../../../utils/redis-keys'
import { createBillingService } from '../billing-service'
import * as schema from '../../../../schemas'
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
return {
get: vi.fn(async (key: string) => defaults[key]),
getOrThrow: vi.fn(async (key: string) => defaults[key]),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
set: vi.fn(),
} as any
}
describe('billingService', () => {
let db: Database
let redis: ReturnType<typeof createTestRedis>
let set: ReturnType<typeof vi.spyOn>
let billingService: ReturnType<typeof createBillingService>
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values({
id: 'user-billing-1',
name: 'Billing User',
email: 'billing@example.com',
})
})
beforeEach(async () => {
redis = createTestRedis()
set = vi.spyOn(redis, 'set')
billingService = createBillingService(db, redis, createMockConfigKV())
await db.delete(schema.fluxTransaction)
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
await db.delete(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1'))
await db.insert(schema.stripeCheckoutSession).values({
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
mode: 'payment',
status: 'complete',
paymentStatus: 'paid',
amountTotal: 500,
currency: 'usd',
fluxCredited: false,
})
})
describe('creditFluxFromStripeCheckout', () => {
it('credits flux, records transaction, and enqueues outbox events in one transaction', async () => {
const result = await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
})
expect(result).toEqual({ applied: true, balanceAfter: 50 })
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(50)
// Verify transaction entry
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
expect(txRecords).toHaveLength(1)
expect(txRecords[0]?.type).toBe('credit')
expect(txRecords[0]?.amount).toBe(50)
expect(txRecords[0]?.balanceBefore).toBe(0)
expect(txRecords[0]?.balanceAfter).toBe(50)
// Verify metadata on transaction entry
expect(txRecords[0]?.metadata).toMatchObject({
stripeEventId: 'stripe-evt-1',
stripeSessionId: 'sess-billing-1',
source: 'stripe.checkout.completed',
})
// Verify stripe session marked as credited
const [sessionRecord] = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1'))
expect(sessionRecord?.fluxCredited).toBe(true)
// Verify Redis cache updated
expect(set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '50')
})
it('is idempotent when the checkout session was already credited', async () => {
await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
})
const second = await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
})
expect(second).toEqual({ applied: false })
// Idempotent replay must not double-write the ledger
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
expect(txRecords).toHaveLength(1)
})
})
describe('consumeFluxForLLM', () => {
it('deducts balance, writes the ledger row inside the transaction, and refreshes Redis', async () => {
// Setup: give user some flux first
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 100 })
const result = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 30,
requestId: 'req-1',
description: 'gpt-4',
promptTokens: 120,
completionTokens: 80,
})
expect(result).toEqual({ userId: 'user-billing-1', flux: 70, charged: 30, requested: 30 })
// Verify DB balance
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(70)
// Ledger row written inline (no async consumer involved post-refactor)
const [txRecord] = await db.select().from(schema.fluxTransaction).where(and(
eq(schema.fluxTransaction.userId, 'user-billing-1'),
eq(schema.fluxTransaction.requestId, 'req-1'),
))
expect(txRecord).toMatchObject({
userId: 'user-billing-1',
type: 'debit',
amount: 30,
balanceBefore: 100,
balanceAfter: 70,
requestId: 'req-1',
description: 'gpt-4',
})
expect(txRecord?.metadata).toMatchObject({
promptTokens: 120,
completionTokens: 80,
source: 'llm.request',
})
// Verify Redis cache updated
expect(set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '70')
})
// ROOT CAUSE:
//
// Before: when `0 < balance < amount`, debitFlux threw and rolled back the
// whole tx. The streaming proxy had already delivered the response, so the
// unpaid request was logged but the user's balance was untouched.
// A scripted attacker on a partial balance could replay forever — balance
// never moved, line 129 (`flux <= 0`) kept letting requests through, and
// every call landed in the catch path crediting `fluxUnbilled` for the
// full amount.
//
// After: balance is drained to zero, the ledger records `amount = charged`
// plus `metadata.requestedAmount` / `metadata.unbilled`, and the caller
// gets `charged < requested` so it can attribute the leak to
// `fluxUnbilled{reason="partial_debit_drained"}`. The next request from
// the same user is rejected at the pre-flight gate.
it('partial-debits when balance is below the requested amount and writes unbilled metadata (Issue: unpaid-usage-exploit)', async () => {
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 5 })
const result = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 38,
requestId: 'req-partial',
description: 'gpt-4',
})
expect(result).toEqual({ userId: 'user-billing-1', flux: 0, charged: 5, requested: 38 })
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(0)
const [txRecord] = await db.select().from(schema.fluxTransaction).where(and(
eq(schema.fluxTransaction.userId, 'user-billing-1'),
eq(schema.fluxTransaction.requestId, 'req-partial'),
))
expect(txRecord).toMatchObject({
type: 'debit',
amount: 5,
balanceBefore: 5,
balanceAfter: 0,
})
expect(txRecord?.metadata).toMatchObject({
source: 'llm.request',
requestedAmount: 38,
unbilled: 33,
})
// Redis cache reflects the zero balance, so the next pre-flight gate
// (`flux < fallbackRate`) rejects immediately.
expect(set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '0')
})
it('throws 402 when balance is already zero (no ledger row, no balance change)', async () => {
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 0 })
await expect(billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 10,
})).rejects.toThrow('Insufficient flux')
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(0)
const txRecords = await db.select().from(schema.fluxTransaction)
expect(txRecords).toHaveLength(0)
})
it('idempotent replay returns the historical charge without re-debiting (partial debits stay partial on retry)', async () => {
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 5 })
const first = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 38,
requestId: 'req-replay',
})
const second = await billingService.consumeFluxForLLM({
userId: 'user-billing-1',
amount: 38,
requestId: 'req-replay',
})
expect(first.charged).toBe(5)
expect(first.flux).toBe(0)
// Replay reflects the original partial outcome — equal `charged` and
// `requested` prevent the streaming caller from double-firing
// `fluxUnbilled` on retries.
expect(second.charged).toBe(5)
expect(second.requested).toBe(5)
expect(second.flux).toBe(0)
// Ledger has exactly one row for `req-replay`
const txRecords = await db.select().from(schema.fluxTransaction).where(and(
eq(schema.fluxTransaction.userId, 'user-billing-1'),
eq(schema.fluxTransaction.requestId, 'req-replay'),
))
expect(txRecords).toHaveLength(1)
})
})
describe('creditFlux', () => {
it('credits balance and writes the ledger row in one transaction', async () => {
const result = await billingService.creditFlux({
userId: 'user-billing-1',
amount: 50,
description: 'Admin grant',
source: 'admin',
})
expect(result.balanceAfter).toBe(50)
expect(result.balanceBefore).toBe(0)
expect(result.idempotent).toBe(false)
// Verify transaction
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
expect(txRecords).toHaveLength(1)
expect(txRecords[0]).toMatchObject({
type: 'credit',
amount: 50,
balanceBefore: 0,
balanceAfter: 50,
})
})
it('is idempotent across retries with the same requestId', async () => {
// ROOT CAUSE:
//
// Worker crash window: creditFlux commits the credit, then the
// grant-batch poller crashes before marking its own state row
// (e.g. flux_grant_batch_recipient) as granted. On restart the poller
// re-claims the same row and calls creditFlux again with the same
// requestId.
//
// Before the fix: second call hit the unique index on
// (user_id, request_id) and threw, the poller's catch block marked
// the recipient as `failed` despite the user already having been credited.
// User got the FLUX but the recipient row was stuck in failed.
//
// After the fix: second call detects the existing flux_transaction row,
// returns it as an idempotent success without touching balance or cache.
// Poller advances to granted normally.
const requestId = 'campaign-replay-test'
const first = await billingService.creditFlux({
userId: 'user-billing-1',
amount: 100,
requestId,
description: 'Replay test',
source: 'admin',
})
expect(first.idempotent).toBe(false)
expect(first.balanceAfter).toBe(100)
// Second call with same requestId — simulates crash-recovery retry.
const second = await billingService.creditFlux({
userId: 'user-billing-1',
amount: 100,
requestId,
description: 'Replay test',
source: 'admin',
})
expect(second.idempotent).toBe(true)
// Same record returned, not a fresh credit
expect(second.fluxTransactionId).toBe(first.fluxTransactionId)
expect(second.balanceAfter).toBe(first.balanceAfter)
// Balance must NOT have doubled
const [fluxRow] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRow!.flux).toBe(100)
// Only one ledger row exists (unique index would prevent a second anyway,
// but verify the function didn't try to insert and silently swallow)
const txRecords = await db.select().from(schema.fluxTransaction).where(and(
eq(schema.fluxTransaction.userId, 'user-billing-1'),
eq(schema.fluxTransaction.requestId, requestId),
))
expect(txRecords).toHaveLength(1)
})
})
describe('setFlux', () => {
it('sets the balance to an absolute value and records an admin_set ledger row', async () => {
// Start from a known balance so the delta direction is observable.
await billingService.creditFlux({ userId: 'user-billing-1', amount: 100, description: 'seed', source: 'test' })
const result = await billingService.setFlux({
userId: 'user-billing-1',
balance: 250,
description: 'admin top-up',
issuedByUserId: 'admin-1',
})
expect(result.balanceBefore).toBe(100)
expect(result.balanceAfter).toBe(250)
const [fluxRow] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRow!.flux).toBe(250)
const [tx] = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.id, result.fluxTransactionId))
expect(tx!.type).toBe('admin_set')
expect(tx!.amount).toBe(150)
expect(tx!.balanceBefore).toBe(100)
expect(tx!.balanceAfter).toBe(250)
expect(tx!.metadata).toMatchObject({ source: 'admin_set', direction: 'credit', requestedBalance: 250, issuedByUserId: 'admin-1' })
})
it('can zero out a balance and records the debit direction (the primary testing use case)', async () => {
await billingService.creditFlux({ userId: 'user-billing-1', amount: 500, description: 'seed', source: 'test' })
const result = await billingService.setFlux({
userId: 'user-billing-1',
balance: 0,
description: 'admin zero',
issuedByUserId: 'admin-1',
})
expect(result.balanceBefore).toBe(500)
expect(result.balanceAfter).toBe(0)
const [fluxRow] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRow!.flux).toBe(0)
const [tx] = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.id, result.fluxTransactionId))
expect(tx!.type).toBe('admin_set')
expect(tx!.amount).toBe(500)
expect(tx!.metadata).toMatchObject({ direction: 'debit', requestedBalance: 0 })
})
it('initializes a user_flux row when none exists and invalidates the Redis cache', async () => {
// Pre-warm the cache with a stale value to prove setFlux drops it.
await redis.set(userFluxRedisKey('user-billing-1'), '999')
const del = vi.spyOn(redis, 'del')
const result = await billingService.setFlux({
userId: 'user-billing-1',
balance: 42,
description: 'admin set from zero',
issuedByUserId: 'admin-1',
})
expect(result.balanceBefore).toBe(0)
expect(result.balanceAfter).toBe(42)
// Invalidate, not write: next getFlux miss reloads truth from Postgres.
expect(del).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'))
expect(await redis.get(userFluxRedisKey('user-billing-1'))).toBeNull()
})
})
})