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
Generated
+67
@@ -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: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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