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:
RainbowBird
2026-08-15 21:43:30 +08:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 44cd0cfe68
commit ab5e43ae0c
10 changed files with 141 additions and 206 deletions
+3 -1
View File
@@ -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:"
}
}
+18
View File
@@ -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')