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>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
44cd0cfe68
commit
ab5e43ae0c
@@ -0,0 +1,18 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import RedisMock from 'ioredis-mock'
|
||||
|
||||
let nextPort = 16379
|
||||
|
||||
/**
|
||||
* Creates an isolated in-memory Redis client for one test.
|
||||
*
|
||||
* ioredis-mock shares state between clients that use the same host and port.
|
||||
* A unique port keeps unrelated tests from reading each other's keys, while
|
||||
* `duplicate()` still shares state with the client that created it.
|
||||
*/
|
||||
export function createTestRedis(): Redis {
|
||||
const redis = new RedisMock(nextPort, '127.0.0.1')
|
||||
nextPort += 1
|
||||
return redis
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Hono } from 'hono'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createStripeRoutes, formatPrice } from '.'
|
||||
import { createTestRedis } from '../../libs/tests/redis'
|
||||
import { ApiError } from '../../utils/error'
|
||||
import { createCheckoutOperation } from './operations/checkout'
|
||||
import { createWebhookOperation } from './operations/webhook'
|
||||
@@ -81,15 +82,6 @@ function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVServic
|
||||
} as any
|
||||
}
|
||||
|
||||
function createMockRedis(): any {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string) => { store.set(key, value) }),
|
||||
del: vi.fn(async (key: string) => { store.delete(key) }),
|
||||
}
|
||||
}
|
||||
|
||||
const testEnv = {
|
||||
STRIPE_SECRET_KEY: 'sk_test_fake',
|
||||
STRIPE_WEBHOOK_SECRET: 'whsec_test_fake',
|
||||
@@ -155,7 +147,7 @@ function createTestApp(
|
||||
configKV: ConfigKVService,
|
||||
envOverrides: Record<string, any> = {},
|
||||
) {
|
||||
const routes = createStripeRoutes(fluxService, stripeService, billingService, configKV, { ...testEnv, ...envOverrides }, createMockRedis())
|
||||
const routes = createStripeRoutes(fluxService, stripeService, billingService, configKV, { ...testEnv, ...envOverrides }, createTestRedis())
|
||||
const app = new Hono<HonoEnv>()
|
||||
|
||||
app.onError((err, c) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { Database } from '../../../../libs/db'
|
||||
import type { createConfigKVService } from '../../../adapters/config-kv'
|
||||
|
||||
@@ -7,6 +5,7 @@ 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'
|
||||
|
||||
@@ -22,24 +21,10 @@ function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<
|
||||
} as any
|
||||
}
|
||||
|
||||
function createMockRedis(): Redis {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
return 'OK'
|
||||
}),
|
||||
del: vi.fn(async (key: string) => {
|
||||
const existed = store.delete(key)
|
||||
return existed ? 1 : 0
|
||||
}),
|
||||
} as unknown as Redis
|
||||
}
|
||||
|
||||
describe('billingService', () => {
|
||||
let db: Database
|
||||
let redis: Redis
|
||||
let redis: ReturnType<typeof createTestRedis>
|
||||
let set: ReturnType<typeof vi.spyOn>
|
||||
let billingService: ReturnType<typeof createBillingService>
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -53,7 +38,8 @@ describe('billingService', () => {
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
redis = createMockRedis()
|
||||
redis = createTestRedis()
|
||||
set = vi.spyOn(redis, 'set')
|
||||
billingService = createBillingService(db, redis, createMockConfigKV())
|
||||
|
||||
await db.delete(schema.fluxTransaction)
|
||||
@@ -108,7 +94,7 @@ describe('billingService', () => {
|
||||
expect(sessionRecord?.fluxCredited).toBe(true)
|
||||
|
||||
// Verify Redis cache updated
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '50')
|
||||
expect(set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '50')
|
||||
})
|
||||
|
||||
it('is idempotent when the checkout session was already credited', async () => {
|
||||
@@ -179,7 +165,7 @@ describe('billingService', () => {
|
||||
})
|
||||
|
||||
// Verify Redis cache updated
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '70')
|
||||
expect(set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '70')
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
@@ -230,7 +216,7 @@ describe('billingService', () => {
|
||||
|
||||
// Redis cache reflects the zero balance, so the next pre-flight gate
|
||||
// (`flux < fallbackRate`) rejects immediately.
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '0')
|
||||
expect(set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '0')
|
||||
})
|
||||
|
||||
it('throws 402 when balance is already zero (no ledger row, no balance change)', async () => {
|
||||
@@ -412,6 +398,7 @@ describe('billingService', () => {
|
||||
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',
|
||||
@@ -423,7 +410,7 @@ describe('billingService', () => {
|
||||
expect(result.balanceBefore).toBe(0)
|
||||
expect(result.balanceAfter).toBe(42)
|
||||
// Invalidate, not write: next getFlux miss reloads truth from Postgres.
|
||||
expect(redis.del).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'))
|
||||
expect(del).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'))
|
||||
expect(await redis.get(userFluxRedisKey('user-billing-1'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,61 +1,10 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { BillingService } from '../billing-service'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createTestRedis } from '../../../../libs/tests/redis'
|
||||
import { createFluxMeter } from '../flux-meter'
|
||||
|
||||
function createMockRedis() {
|
||||
const store = new Map<string, number>()
|
||||
|
||||
// NOTICE: Mimic the subset of EVAL semantics used by ACCUMULATE_SCRIPT
|
||||
// (INCRBY + EXPIRE + conditional DECRBY). Sufficient for unit tests; the real
|
||||
// atomicity is verified by ioredis hitting Redis in integration.
|
||||
const evalImpl = vi.fn(async (
|
||||
_script: string,
|
||||
_numKeys: number,
|
||||
key: string,
|
||||
units: string | number,
|
||||
unitsPerFlux: string | number,
|
||||
_ttl: string | number,
|
||||
) => {
|
||||
const u = Number(units)
|
||||
const upf = Number(unitsPerFlux)
|
||||
const debt = (store.get(key) ?? 0) + u
|
||||
store.set(key, debt)
|
||||
if (debt >= upf) {
|
||||
const flux = Math.floor(debt / upf)
|
||||
const consumed = flux * upf
|
||||
store.set(key, debt - consumed)
|
||||
return [flux, debt - consumed]
|
||||
}
|
||||
return [0, debt]
|
||||
})
|
||||
|
||||
const incrby = vi.fn(async (key: string, amount: number) => {
|
||||
const next = (store.get(key) ?? 0) + amount
|
||||
store.set(key, next)
|
||||
return next
|
||||
})
|
||||
|
||||
const expire = vi.fn(async () => 1)
|
||||
|
||||
return {
|
||||
redis: {
|
||||
eval: evalImpl,
|
||||
incrby,
|
||||
expire,
|
||||
get: vi.fn(async (key: string) => {
|
||||
const v = store.get(key)
|
||||
return v == null ? null : String(v)
|
||||
}),
|
||||
} as unknown as Redis,
|
||||
store,
|
||||
incrby,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockBilling(opts: { throwOn?: number, partialChargeOn?: { amount: number, charged: number } } = {}): BillingService {
|
||||
return {
|
||||
consumeFluxForLLM: vi.fn(async ({ userId, amount }: { userId: string, amount: number }) => {
|
||||
@@ -86,16 +35,18 @@ function staticRuntime(unitsPerFlux = 1000, debtTtlSeconds = 60) {
|
||||
}
|
||||
|
||||
describe('fluxMeter', () => {
|
||||
let mockRedis: ReturnType<typeof createMockRedis>
|
||||
let redis: ReturnType<typeof createTestRedis>
|
||||
let incrby: ReturnType<typeof vi.spyOn>
|
||||
let billing: BillingService
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = createMockRedis()
|
||||
redis = createTestRedis()
|
||||
incrby = vi.spyOn(redis, 'incrby')
|
||||
billing = createMockBilling()
|
||||
})
|
||||
|
||||
it('does not debit when accumulated units stay below threshold', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
const result = await meter.accumulate({
|
||||
userId: 'u1',
|
||||
@@ -109,7 +60,7 @@ describe('fluxMeter', () => {
|
||||
})
|
||||
|
||||
it('debits exactly one flux when crossing the threshold', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
await meter.accumulate({ userId: 'u1', units: 700, currentBalance: 10, requestId: 'a' })
|
||||
const result = await meter.accumulate({ userId: 'u1', units: 400, currentBalance: 10, requestId: 'b' })
|
||||
@@ -125,7 +76,7 @@ describe('fluxMeter', () => {
|
||||
})
|
||||
|
||||
it('debits multiple flux when one request crosses several thresholds', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
const result = await meter.accumulate({ userId: 'u1', units: 3500, currentBalance: 10, requestId: 'big' })
|
||||
|
||||
@@ -135,7 +86,7 @@ describe('fluxMeter', () => {
|
||||
})
|
||||
|
||||
it('returns 0 fluxDebited for zero, negative, or non-finite units', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
const result = await meter.accumulate({ userId: 'u1', units: bad, currentBalance: 10, requestId: 'x' })
|
||||
@@ -145,23 +96,23 @@ describe('fluxMeter', () => {
|
||||
})
|
||||
|
||||
it('throws 402 when projected debt would exceed user balance', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
await expect(meter.assertCanAfford('u1', 5000, 2)).rejects.toMatchObject({ statusCode: 402 })
|
||||
})
|
||||
|
||||
it('allows sub-threshold accumulation when balance >= 1', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
await expect(meter.assertCanAfford('u1', 200, 1)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects sub-threshold accumulation when balance is zero', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
await expect(meter.assertCanAfford('u1', 200, 0)).rejects.toMatchObject({ statusCode: 402 })
|
||||
})
|
||||
|
||||
it('throws from runtime resolver when unitsPerFlux is invalid', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, {
|
||||
const meter = createFluxMeter(redis, billing, {
|
||||
name: 'bad',
|
||||
resolveRuntime: async () => ({ unitsPerFlux: 0, debtTtlSeconds: 60 }),
|
||||
})
|
||||
@@ -169,7 +120,7 @@ describe('fluxMeter', () => {
|
||||
})
|
||||
|
||||
it('peekDebt reflects current accumulated units', async () => {
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
await meter.accumulate({ userId: 'u1', units: 250, currentBalance: 10, requestId: 'p' })
|
||||
expect(await meter.peekDebt('u1')).toBe(250)
|
||||
@@ -178,14 +129,14 @@ describe('fluxMeter', () => {
|
||||
it('does not read config at construction time (lazy resolver)', async () => {
|
||||
const resolver = staticRuntime()
|
||||
|
||||
createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: resolver })
|
||||
createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: resolver })
|
||||
|
||||
expect(resolver).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves runtime on every call so multi-instance config changes propagate immediately', async () => {
|
||||
const resolver = staticRuntime()
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: resolver })
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: resolver })
|
||||
|
||||
await meter.accumulate({ userId: 'u1', units: 100, currentBalance: 10, requestId: 'a' })
|
||||
await meter.accumulate({ userId: 'u1', units: 100, currentBalance: 10, requestId: 'b' })
|
||||
@@ -197,7 +148,7 @@ describe('fluxMeter', () => {
|
||||
it('restores debt back into the counter when billing debit throws', async () => {
|
||||
// Billing rejects the exact flux amount we expect to settle.
|
||||
const failingBilling = createMockBilling({ throwOn: 2 })
|
||||
const meter = createFluxMeter(mockRedis.redis, failingBilling, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
const meter = createFluxMeter(redis, failingBilling, { name: 'tts', resolveRuntime: staticRuntime() })
|
||||
|
||||
await expect(
|
||||
meter.accumulate({ userId: 'u1', units: 2500, currentBalance: 10, requestId: 'fail' }),
|
||||
@@ -206,7 +157,7 @@ describe('fluxMeter', () => {
|
||||
// Settlement was rolled back: 2500 units should be fully recovered
|
||||
// (500 residual + 2000 rolled back), not 500.
|
||||
expect(await meter.peekDebt('u1')).toBe(2500)
|
||||
expect(mockRedis.incrby).toHaveBeenCalledWith(expect.stringContaining('u1'), 2000)
|
||||
expect(incrby).toHaveBeenCalledWith(expect.stringContaining('u1'), 2000)
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
@@ -236,7 +187,7 @@ describe('fluxMeter', () => {
|
||||
// - fluxUnbilled metric incremented by 2 with partial_debit_drained reason
|
||||
const partialBilling = createMockBilling({ partialChargeOn: { amount: 3, charged: 1 } })
|
||||
const { metrics, fluxUnbilled } = createMockMetrics()
|
||||
const meter = createFluxMeter(mockRedis.redis, partialBilling, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
|
||||
const meter = createFluxMeter(redis, partialBilling, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
|
||||
|
||||
const result = await meter.accumulate({
|
||||
userId: 'u1',
|
||||
@@ -251,7 +202,7 @@ describe('fluxMeter', () => {
|
||||
expect(result.balanceAfter).toBe(0)
|
||||
// Debt = 500 residual (LUA leftover) + 2000 restored from partial drain.
|
||||
expect(await meter.peekDebt('u1')).toBe(2500)
|
||||
expect(mockRedis.incrby).toHaveBeenCalledWith(expect.stringContaining('u1'), 2000)
|
||||
expect(incrby).toHaveBeenCalledWith(expect.stringContaining('u1'), 2000)
|
||||
expect(fluxUnbilled.add).toHaveBeenCalledWith(2, expect.objectContaining({
|
||||
'source': 'tts_meter',
|
||||
'meter': 'tts',
|
||||
@@ -262,7 +213,7 @@ describe('fluxMeter', () => {
|
||||
|
||||
it('does not report fluxUnbilled when billing fully charges', async () => {
|
||||
const { metrics, fluxUnbilled } = createMockMetrics()
|
||||
const meter = createFluxMeter(mockRedis.redis, billing, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
|
||||
const meter = createFluxMeter(redis, billing, { name: 'tts', resolveRuntime: staticRuntime() }, metrics)
|
||||
|
||||
const result = await meter.accumulate({ userId: 'u1', units: 1500, currentBalance: 10, requestId: 'full' })
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import type { Database } from '../../libs/db'
|
||||
import type { createConfigKVService } from '../adapters/config-kv'
|
||||
|
||||
@@ -7,6 +5,7 @@ import { 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 { createFluxService } from './flux'
|
||||
|
||||
@@ -22,20 +21,11 @@ function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<
|
||||
} as any
|
||||
}
|
||||
|
||||
function createMockRedis(): Redis {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
return 'OK'
|
||||
}),
|
||||
} as unknown as Redis
|
||||
}
|
||||
|
||||
describe('fluxService (DB-backed)', () => {
|
||||
let db: Database
|
||||
let redis: Redis
|
||||
let redis: ReturnType<typeof createTestRedis>
|
||||
let get: ReturnType<typeof vi.spyOn>
|
||||
let set: ReturnType<typeof vi.spyOn>
|
||||
let service: ReturnType<typeof createFluxService>
|
||||
let testUser: any
|
||||
|
||||
@@ -51,7 +41,9 @@ describe('fluxService (DB-backed)', () => {
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
redis = createMockRedis()
|
||||
redis = createTestRedis()
|
||||
get = vi.spyOn(redis, 'get')
|
||||
set = vi.spyOn(redis, 'set')
|
||||
service = createFluxService(db, redis, createMockConfigKV())
|
||||
|
||||
// Clean up flux-related tables
|
||||
@@ -62,7 +54,7 @@ describe('fluxService (DB-backed)', () => {
|
||||
it('getFlux should initialize new user with INITIAL_USER_FLUX and populate Redis', async () => {
|
||||
const record = await service.getFlux(testUser.id)
|
||||
expect(record.flux).toBe(100)
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '100')
|
||||
expect(set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '100')
|
||||
})
|
||||
|
||||
it('getFlux should write a transaction entry on initialization', async () => {
|
||||
@@ -82,7 +74,7 @@ describe('fluxService (DB-backed)', () => {
|
||||
await service.getFlux(testUser.id)
|
||||
await service.getFlux(testUser.id)
|
||||
// Second call hits Redis cache
|
||||
expect(redis.get).toHaveBeenCalledTimes(2)
|
||||
expect(get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('getFlux should load from DB when Redis cache misses', async () => {
|
||||
@@ -91,7 +83,7 @@ describe('fluxService (DB-backed)', () => {
|
||||
|
||||
const record = await service.getFlux(testUser.id)
|
||||
expect(record.flux).toBe(42)
|
||||
expect(redis.set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '42')
|
||||
expect(set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '42')
|
||||
})
|
||||
|
||||
it('updateStripeCustomerId should update DB only', async () => {
|
||||
|
||||
@@ -1,75 +1,13 @@
|
||||
import type Redis from 'ioredis'
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createTestRedis } from '../../../../libs/tests/redis'
|
||||
import { createConcurrencyLedger } from '../concurrency-ledger'
|
||||
|
||||
// NOTICE: Mimic the subset of Redis semantics the ledger uses (EVAL for the
|
||||
// ACQUIRE/RELEASE Lua, plus SET/EXISTS/GET/SADD/SMEMBERS/MGET). The two Lua
|
||||
// scripts are told apart by numKeys (acquire passes 2 keys, release passes 1) —
|
||||
// same approach flux-meter.test.ts uses for its single script. Real Lua
|
||||
// atomicity is exercised by ioredis hitting Redis in integration; here we verify
|
||||
// the capacity decision, floor-guarded release, saturation flags, and snapshot.
|
||||
function createMockRedis() {
|
||||
const inflight = new Map<string, number>()
|
||||
const saturated = new Set<string>()
|
||||
const known = new Set<string>()
|
||||
|
||||
const evalImpl = async (_script: string, numKeys: number, ...args: Array<string | number>) => {
|
||||
if (numKeys === 2) {
|
||||
// ACQUIRE_SCRIPT: inflightKey, knownKey, max, ttl, poolId
|
||||
const inflightKey = String(args[0])
|
||||
const knownKey = String(args[1])
|
||||
const max = Number(args[2])
|
||||
const poolId = String(args[4])
|
||||
const current = inflight.get(inflightKey) ?? 0
|
||||
if (current < max) {
|
||||
const next = current + 1
|
||||
inflight.set(inflightKey, next)
|
||||
known.add(`${knownKey}::${poolId}`)
|
||||
return next
|
||||
}
|
||||
return -1
|
||||
}
|
||||
// RELEASE_SCRIPT: inflightKey
|
||||
const inflightKey = String(args[0])
|
||||
const current = inflight.get(inflightKey) ?? 0
|
||||
if (current > 0) {
|
||||
const next = current - 1
|
||||
inflight.set(inflightKey, next)
|
||||
return next
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const redis = {
|
||||
eval: evalImpl,
|
||||
set: async (key: string, _val: string, _mode: string, _ttl: number) => {
|
||||
saturated.add(key)
|
||||
return 'OK'
|
||||
},
|
||||
exists: async (key: string) => (saturated.has(key) ? 1 : 0),
|
||||
get: async (key: string) => {
|
||||
const v = inflight.get(key)
|
||||
return v == null ? null : String(v)
|
||||
},
|
||||
smembers: async (key: string) => {
|
||||
const prefix = `${key}::`
|
||||
return [...known].filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length))
|
||||
},
|
||||
mget: async (keys: string[]) => keys.map(k => (inflight.has(k) ? String(inflight.get(k)) : null)),
|
||||
} as unknown as Redis
|
||||
|
||||
return { redis, inflight, saturated }
|
||||
}
|
||||
|
||||
describe('concurrencyLedger', () => {
|
||||
let mock: ReturnType<typeof createMockRedis>
|
||||
let ledger: ReturnType<typeof createConcurrencyLedger>
|
||||
|
||||
beforeEach(() => {
|
||||
mock = createMockRedis()
|
||||
ledger = createConcurrencyLedger(mock.redis)
|
||||
ledger = createConcurrencyLedger(createTestRedis())
|
||||
})
|
||||
|
||||
it('tryAcquire grants a slot while the pool is below max and increments inflight', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mockDB } from '../../../../libs/mock-db'
|
||||
import { createTestRedis } from '../../../../libs/tests/redis'
|
||||
import { createCharacterService } from '../../characters'
|
||||
import { createChatService } from '../../chats'
|
||||
import { createFluxService } from '../../flux'
|
||||
@@ -11,22 +12,6 @@ import { createProviderService } from '../../providers'
|
||||
|
||||
import * as schema from '../../../../schemas'
|
||||
|
||||
function fakeRedis() {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
get: vi.fn(async (k: string) => map.get(k) ?? null),
|
||||
set: vi.fn(async (k: string, v: string) => {
|
||||
map.set(k, v)
|
||||
return 'OK'
|
||||
}),
|
||||
del: vi.fn(async (k: string) => {
|
||||
const had = map.has(k)
|
||||
map.delete(k)
|
||||
return had ? 1 : 0
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
function fakeConfigKV() {
|
||||
return {
|
||||
get: vi.fn(async () => undefined),
|
||||
@@ -46,21 +31,22 @@ describe('fluxService.deleteAllForUser', () => {
|
||||
await db.insert(schema.user).values({ id: 'u-flux-1', name: 'A', email: 'a@example.com' })
|
||||
await db.insert(schema.userFlux).values({ userId: 'u-flux-1', flux: 100 })
|
||||
|
||||
const redis = fakeRedis()
|
||||
const redis = createTestRedis()
|
||||
const del = vi.spyOn(redis, 'del')
|
||||
const service = createFluxService(db, redis, fakeConfigKV())
|
||||
await service.deleteAllForUser('u-flux-1')
|
||||
|
||||
const row = await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-1') })
|
||||
expect(row?.deletedAt).toBeInstanceOf(Date)
|
||||
expect(redis.del).toHaveBeenCalledTimes(1)
|
||||
expect(redis.del).toHaveBeenCalledWith(expect.stringContaining('u-flux-1'))
|
||||
expect(del).toHaveBeenCalledTimes(1)
|
||||
expect(del).toHaveBeenCalledWith(expect.stringContaining('u-flux-1'))
|
||||
})
|
||||
|
||||
it('is idempotent on retry — already-soft-deleted rows stay unchanged', async () => {
|
||||
await db.insert(schema.user).values({ id: 'u-flux-2', name: 'B', email: 'b@example.com' })
|
||||
await db.insert(schema.userFlux).values({ userId: 'u-flux-2', flux: 50 })
|
||||
|
||||
const redis = fakeRedis()
|
||||
const redis = createTestRedis()
|
||||
const service = createFluxService(db, redis, fakeConfigKV())
|
||||
|
||||
await service.deleteAllForUser('u-flux-2')
|
||||
|
||||
Reference in New Issue
Block a user