From ab5e43ae0cbaf5fed6dea42727a2ab1ed2fb8fc5 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sat, 15 Aug 2026 21:43:30 +0800 Subject: [PATCH] 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. ## 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. --------- Signed-off-by: RainbowBird Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 67 ++++++++++++++ pnpm-workspace.yaml | 2 + server/apps/api/package.json | 4 +- server/apps/api/src/libs/tests/redis.ts | 18 ++++ .../apps/api/src/routes/stripe/route.test.ts | 12 +-- .../billing/tests/billing-service.test.ts | 33 ++----- .../domain/billing/tests/flux-meter.test.ts | 91 +++++-------------- .../apps/api/src/services/domain/flux.test.ts | 28 ++---- .../tests/concurrency-ledger.test.ts | 66 +------------- .../tests/service-deletion.test.ts | 26 ++---- 10 files changed, 141 insertions(+), 206 deletions(-) create mode 100644 server/apps/api/src/libs/tests/redis.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25dee6fd2..f526d1a27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -450,6 +450,9 @@ catalogs: '@types/hast': specifier: ^3.0.4 version: 3.0.4 + '@types/ioredis-mock': + specifier: ^8.2.8 + version: 8.2.8 '@types/markdown-it': specifier: ^14.1.2 version: 14.1.2 @@ -759,6 +762,9 @@ catalogs: ioredis: specifier: ^5.10.1 version: 5.10.1 + ioredis-mock: + specifier: ^8.13.1 + version: 8.13.1 is-network-error: specifier: ^1.3.1 version: 1.3.1 @@ -5480,6 +5486,9 @@ importers: specifier: 'catalog:' version: 4.4.3 devDependencies: + '@types/ioredis-mock': + specifier: 'catalog:' + version: 8.2.8(ioredis@5.10.1) '@types/pg': specifier: 'catalog:' version: 8.20.0 @@ -5489,6 +5498,9 @@ importers: drizzle-kit: specifier: 'catalog:' version: 0.31.10 + ioredis-mock: + specifier: 'catalog:' + version: 8.13.1(@types/ioredis-mock@8.2.8(ioredis@5.10.1))(ioredis@5.10.1) server/apps/auth: dependencies: @@ -7989,6 +8001,9 @@ packages: resolution: {integrity: sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==} engines: {node: '>=16.0.0'} + '@ioredis/as-callback@3.0.0': + resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} + '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} @@ -11239,6 +11254,11 @@ packages: '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + '@types/ioredis-mock@8.2.8': + resolution: {integrity: sha512-Nxr1CXk8NtGVzWQ8WoGCOyo+HARd4CFc/Nt2+ITt3WWLNlj1InW38SzZwOJNqzA38iplrpBFp6Ny4KjPfeI+bA==} + peerDependencies: + ioredis: ^5 + '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} @@ -14662,6 +14682,14 @@ packages: feaxios@0.0.23: resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} + fengari-interop@0.1.4: + resolution: {integrity: sha512-4/CW/3PJUo3ebD4ACgE1g/3NGEYSq7OQAyETyypsAl/WeySDBbxExikkayNkZzbpgyC9GyJp8v1DU2VOXxNq7Q==} + peerDependencies: + fengari: ^0.1.0 + + fengari@0.1.5: + resolution: {integrity: sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==} + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -15453,6 +15481,13 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ioredis-mock@8.13.1: + resolution: {integrity: sha512-Wsi50AU+cMiI32nAgfwpUaJVBtb4iQdVsOHl9M6R3tePCO/8vGsToCVIG82XWAxN4Se55TZoOzVseu+QngFLyw==} + engines: {node: '>=12.22'} + peerDependencies: + '@types/ioredis-mock': ^8 + ioredis: ^5 + ioredis@5.10.1: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} @@ -17653,6 +17688,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + real-cancellable-promise@1.2.3: resolution: {integrity: sha512-hBI5Gy/55VEeeMtImMgEirD7eq5UmqJf1J8dFZtbJZA/3rB0pYFZ7PayMGueb6v4UtUtpKpP+05L0VwyE1hI9Q==} @@ -22802,6 +22841,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@ioredis/as-callback@3.0.0': {} + '@ioredis/commands@1.5.1': {} '@isaacs/cliui@8.0.2': @@ -25754,6 +25795,10 @@ snapshots: '@types/http-cache-semantics@4.0.4': {} + '@types/ioredis-mock@8.2.8(ioredis@5.10.1)': + dependencies: + ioredis: 5.10.1 + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} @@ -29877,6 +29922,16 @@ snapshots: dependencies: is-retry-allowed: 3.0.0 + fengari-interop@0.1.4(fengari@0.1.5): + dependencies: + fengari: 0.1.5 + + fengari@0.1.5: + dependencies: + readline-sync: 1.4.10 + sprintf-js: 1.1.3 + tmp: 0.2.5 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -30879,6 +30934,16 @@ snapshots: internmap@2.0.3: {} + ioredis-mock@8.13.1(@types/ioredis-mock@8.2.8(ioredis@5.10.1))(ioredis@5.10.1): + dependencies: + '@ioredis/as-callback': 3.0.0 + '@ioredis/commands': 1.5.1 + '@types/ioredis-mock': 8.2.8(ioredis@5.10.1) + fengari: 0.1.5 + fengari-interop: 0.1.4(fengari@0.1.5) + ioredis: 5.10.1 + semver: 7.7.4 + ioredis@5.10.1: dependencies: '@ioredis/commands': 1.5.1 @@ -33581,6 +33646,8 @@ snapshots: readdirp@5.0.0: {} + readline-sync@1.4.10: {} + real-cancellable-promise@1.2.3: {} real-require@0.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3051c9c8e..4edfe4bb2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -184,6 +184,7 @@ catalog: '@types/culori': ^4.0.1 '@types/d3': ^7.4.3 '@types/hast': ^3.0.4 + '@types/ioredis-mock': ^8.2.8 '@types/markdown-it': ^14.1.2 '@types/node': ^24.12.2 '@types/nprogress': ^0.2.3 @@ -287,6 +288,7 @@ catalog: idb-keyval: ^6.2.2 injeca: ^0.2.0 ioredis: ^5.10.1 + ioredis-mock: ^8.13.1 is-network-error: ^1.3.1 isolated-vm: ^7.0.0 jose: ^6.2.2 diff --git a/server/apps/api/package.json b/server/apps/api/package.json index 2c81d4cb9..ed3d61ca3 100644 --- a/server/apps/api/package.json +++ b/server/apps/api/package.json @@ -66,8 +66,10 @@ "zod": "catalog:" }, "devDependencies": { + "@types/ioredis-mock": "catalog:", "@types/pg": "catalog:", "@types/ws": "catalog:", - "drizzle-kit": "catalog:" + "drizzle-kit": "catalog:", + "ioredis-mock": "catalog:" } } diff --git a/server/apps/api/src/libs/tests/redis.ts b/server/apps/api/src/libs/tests/redis.ts new file mode 100644 index 000000000..4ad1f8180 --- /dev/null +++ b/server/apps/api/src/libs/tests/redis.ts @@ -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 +} diff --git a/server/apps/api/src/routes/stripe/route.test.ts b/server/apps/api/src/routes/stripe/route.test.ts index ebba3cb33..8c52fdfd6 100644 --- a/server/apps/api/src/routes/stripe/route.test.ts +++ b/server/apps/api/src/routes/stripe/route.test.ts @@ -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 = {}): ConfigKVServic } as any } -function createMockRedis(): any { - const store = new Map() - 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 = {}, ) { - const routes = createStripeRoutes(fluxService, stripeService, billingService, configKV, { ...testEnv, ...envOverrides }, createMockRedis()) + const routes = createStripeRoutes(fluxService, stripeService, billingService, configKV, { ...testEnv, ...envOverrides }, createTestRedis()) const app = new Hono() app.onError((err, c) => { diff --git a/server/apps/api/src/services/domain/billing/tests/billing-service.test.ts b/server/apps/api/src/services/domain/billing/tests/billing-service.test.ts index cad281a7f..88b7f10ed 100644 --- a/server/apps/api/src/services/domain/billing/tests/billing-service.test.ts +++ b/server/apps/api/src/services/domain/billing/tests/billing-service.test.ts @@ -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 = {}): ReturnType< } as any } -function createMockRedis(): Redis { - const store = new Map() - 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 + let set: ReturnType let billingService: ReturnType 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() }) }) diff --git a/server/apps/api/src/services/domain/billing/tests/flux-meter.test.ts b/server/apps/api/src/services/domain/billing/tests/flux-meter.test.ts index 0ceb8dfa2..a961bdf9f 100644 --- a/server/apps/api/src/services/domain/billing/tests/flux-meter.test.ts +++ b/server/apps/api/src/services/domain/billing/tests/flux-meter.test.ts @@ -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() - - // 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 + let redis: ReturnType + let incrby: ReturnType 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' }) diff --git a/server/apps/api/src/services/domain/flux.test.ts b/server/apps/api/src/services/domain/flux.test.ts index a8b1ccff2..cf1f4dbb1 100644 --- a/server/apps/api/src/services/domain/flux.test.ts +++ b/server/apps/api/src/services/domain/flux.test.ts @@ -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 = {}): ReturnType< } as any } -function createMockRedis(): Redis { - const store = new Map() - 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 + let get: ReturnType + let set: ReturnType let service: ReturnType 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 () => { diff --git a/server/apps/api/src/services/domain/llm-router/tests/concurrency-ledger.test.ts b/server/apps/api/src/services/domain/llm-router/tests/concurrency-ledger.test.ts index 72195ea8c..99dd4fb03 100644 --- a/server/apps/api/src/services/domain/llm-router/tests/concurrency-ledger.test.ts +++ b/server/apps/api/src/services/domain/llm-router/tests/concurrency-ledger.test.ts @@ -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() - const saturated = new Set() - const known = new Set() - - const evalImpl = async (_script: string, numKeys: number, ...args: Array) => { - 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 let ledger: ReturnType 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 () => { diff --git a/server/apps/api/src/services/domain/user-deletion/tests/service-deletion.test.ts b/server/apps/api/src/services/domain/user-deletion/tests/service-deletion.test.ts index d6842a30f..0011912c9 100644 --- a/server/apps/api/src/services/domain/user-deletion/tests/service-deletion.test.ts +++ b/server/apps/api/src/services/domain/user-deletion/tests/service-deletion.test.ts @@ -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() - 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')