Files
moeka-project/server/apps/api/src/services/adapters/config-kv/store.test.ts
T
RainbowBird 88625a8d84 feat(api): hot-reload ConfigKV from Postgres (#2289)
## Summary

- Add the `config_kv` schema and Drizzle migration `0020`.
- Keep the ConfigKV schema, cache store, and invalidation contract in
the Resource API.
- Read ConfigKV through a five-minute Redis cache with PostgreSQL
fallback.
- Reload Router and TTS voice state through `configkv:invalidate`.
- Keep Auth rate limits fixed at 20 requests per 60 seconds.

## Stack

- Depends on #2294 for the Redis test implementation.
- This PR adds ConfigKV-specific cache-aside and Pub/Sub tests on top of
that implementation.

## Deployment

Run migration `0020` before this runtime reaches production traffic.

Then freeze ConfigKV writes. Audit and backfill the data with
[proj-airi/backend#2](https://github.com/proj-airi/backend/pull/2).
Merge
[proj-airi/backend#4](https://github.com/proj-airi/backend/pull/4)
first, so
the fixed Auth rate-limit keys are skipped.

Keep writes frozen until the hashes match and two API instances pass the
Pub/Sub reload check. This PR does not run production DDL or data
migration.

## Verification

- `pnpm exec vitest run <ConfigKV cache store, sync subscriber, and Auth
rate-limit tests>` (12 tests passed)
- `pnpm -F @proj-airi/api-server typecheck`
- `git diff --check`

See #2294 for its frozen-install, ESLint, and 73-test verification.

## Visual changes

None. This PR changes backend persistence and rate-limit wiring only.


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

## Summary by CodeRabbit

- **New Features**
- Added centralized configuration storage with validation, caching,
refresh, and automatic synchronization across services.
- Configuration updates now refresh related language-model and
text-to-speech settings automatically.

- **Bug Fixes**
- Improved recovery after service reconnects by clearing stale
configuration and reloading current values.
- Invalid or unavailable configuration data now produces clearer
service-unavailable responses.

- **Changes**
- Authentication rate limiting now uses a consistent limit of 20
requests per minute per client.

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

---------

Signed-off-by: RainbowBird <git@luoling.moe>
Signed-off-by: RainbowBird <rbxin2003@outlook.com>
2026-08-15 22:24:32 +08:00

94 lines
3.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../../libs/mock-db'
import { createTestRedis } from '../../../libs/tests/redis'
import { configKV } from '../../../schemas'
import { createConfigKVStore } from './store'
describe('configKV store', () => {
let db: Awaited<ReturnType<typeof mockDB>>
beforeEach(async () => {
db = await mockDB({ configKV })
})
it('returns a Redis cache hit without reading PostgreSQL', async () => {
const redis = createTestRedis()
await redis.set('cache:config:FLUX_PER_REQUEST', '7')
const set = vi.spyOn(redis, 'set')
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('FLUX_PER_REQUEST')).resolves.toBe('7')
expect(set).not.toHaveBeenCalled()
})
it('falls back to PostgreSQL and fills Redis for 300 seconds', async () => {
await db.insert(configKV).values({ key: 'FLUX_PER_REQUEST', value: '8' })
const redis = createTestRedis()
const set = vi.spyOn(redis, 'set')
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('FLUX_PER_REQUEST')).resolves.toBe('8')
expect(set).toHaveBeenCalledWith('cache:config:FLUX_PER_REQUEST', '8', 'EX', 300)
})
it('fails when Redis reads fail', async () => {
await db.insert(configKV).values({ key: 'FLUX_PER_REQUEST', value: '9' })
const redis = createTestRedis()
vi.spyOn(redis, 'get').mockRejectedValueOnce(new Error('redis offline'))
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('FLUX_PER_REQUEST')).rejects.toThrow('redis offline')
})
it('returns null when PostgreSQL has no row', async () => {
const redis = createTestRedis()
const set = vi.spyOn(redis, 'set')
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('DEFAULT_CHAT_MODEL')).resolves.toBeNull()
expect(set).not.toHaveBeenCalled()
})
it('fails when Redis cannot store a PostgreSQL value', async () => {
await db.insert(configKV).values({ key: 'FLUX_PER_REQUEST', value: '9' })
const redis = createTestRedis()
vi.spyOn(redis, 'set').mockRejectedValueOnce(new Error('redis offline'))
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('FLUX_PER_REQUEST')).rejects.toThrow('redis offline')
})
it('deletes the derived cache entry during invalidation', async () => {
const redis = createTestRedis()
await redis.set('cache:config:LLM_ROUTER_CONFIG', '{}')
const del = vi.spyOn(redis, 'del')
const store = createConfigKVStore(db, redis)
await store.invalidateCache('LLM_ROUTER_CONFIG')
expect(del).toHaveBeenCalledWith('cache:config:LLM_ROUTER_CONFIG')
await expect(redis.get('cache:config:LLM_ROUTER_CONFIG')).resolves.toBeNull()
})
it('fails invalidation when Redis cannot delete the derived value', async () => {
const redis = createTestRedis()
vi.spyOn(redis, 'del').mockRejectedValueOnce(new Error('redis offline'))
const store = createConfigKVStore(db, redis)
await expect(store.invalidateCache('LLM_ROUTER_CONFIG')).rejects.toThrow('redis offline')
})
it('removes a stale cache entry when a fresh database read is missing', async () => {
const redis = createTestRedis()
await redis.set('cache:config:FLUX_PER_REQUEST', '20')
const del = vi.spyOn(redis, 'del')
const store = createConfigKVStore(db, redis)
await expect(store.getFreshRaw('FLUX_PER_REQUEST')).resolves.toBeNull()
expect(del).toHaveBeenCalledWith('cache:config:FLUX_PER_REQUEST')
await expect(redis.get('cache:config:FLUX_PER_REQUEST')).resolves.toBeNull()
})
})