airi/apps/server/tests/verifications/_harness.ts
RainbowBird c627bce9c9
refactor(server): split services into domain/adapter layers, drop dead code
Why
- src/services/ was an unordered mix of single-file services and module
  directories with no shared classification axis, plus several long-dead
  admin batch helpers that survived the move to the simpler synchronous
  admin-flux-grants flow.

What
- services/ now has two top-level layers:
    domain/   — DB state + business rules (billing, characters, chats,
                flux, flux-transaction, llm-router, providers, request-log,
                stripe, user-deletion, admin/{flux-grants,router-config})
    adapters/ — thin wrappers over external SDKs / infra (config-kv, email,
                posthog, tts/)
- admin/* moved under domain/admin/ with consistent plural names
  (flux-grants, router-config).
- tts-adapters/ collapsed to adapters/tts/ (no redundant -adapters suffix
  once nested under adapters/).
- 63 src files + scripts/e2e-llm-router.ts + tests/verifications/_harness.ts
  had relative imports rewritten; git mv preserves blame.
- apps/server/CLAUDE.md and docs/ai-context/*.md updated to match new paths.

Dead code removed
- services/admin-flux-grant-batches/ (service + worker + tests, 1090 LOC) —
  superseded by admin-flux-grants and never wired into app.ts.
- routes/admin/flux-grant-batches/ — same.
- utils/redis-compressed.ts + test — zero production call sites.
- llm-router/index.ts re-exports trimmed from 26 to 6; only symbols with
  external consumers are kept.

Intentionally kept
- schemas/flux-grant-batch.ts and its schemas/index.ts export remain so the
  drizzle-kit generate diff stays empty. Removing them is a separate PR
  that owns the drop-table migration for flux_grant_batch /
  flux_grant_batch_recipient.

Verification
- pnpm -F @proj-airi/server typecheck: passes.
- pnpm exec eslint apps/server: 49 errors, identical to main baseline
  (all are pre-existing node/prefer-global/buffer in envelope-crypto and
  scripts/e2e-llm-router; untouched by this change).
- Vitest passes per-file; the 6 mockDB hook timeouts under full-parallel
  run are the known pushSchema-per-worker infra cost, not a regression.
2026-05-18 23:36:45 +08:00

265 lines
8.8 KiB
TypeScript

import type { Database } from '../../src/libs/db'
import { Buffer } from 'node:buffer'
import { vi } from 'vitest'
import { buildApp } from '../../src/app'
import { mockDB } from '../../src/libs/mock-db'
import { createAdminFluxGrantsService } from '../../src/services/domain/admin/flux-grants'
import { createBillingService } from '../../src/services/domain/billing/billing-service'
import { createFluxService } from '../../src/services/domain/flux'
import { createUserDeletionService } from '../../src/services/domain/user-deletion'
import { userFluxRedisKey } from '../../src/utils/redis-keys'
import * as schema from '../../src/schemas'
// NOTICE:
// drizzle-kit's `pushSchema` (called by `mockDB`) takes ~500ms per invocation.
// Vitest spawns a fresh worker per test file, so a module-level promise scopes
// the cache correctly: schema push runs once per file, every
// `startVerificationContext()` after that reuses the in-memory PGlite and
// only truncates rows. See `docs/ai/context/verification-automation.md` for
// the broader rationale on test boot cost.
let sharedDbPromise: Promise<Database> | null = null
async function getSharedDb(): Promise<Database> {
sharedDbPromise ??= mockDB(schema)
return sharedDbPromise
}
async function resetDataRows(db: Database): Promise<void> {
// Delete in FK-safe order. better-auth's session / account / verification
// tables reference user with `onDelete: cascade`, so deleting `user` last
// implicitly clears them — we still call them out for clarity and so a
// future test that seeds sessions directly does not silently leak rows.
await db.delete(schema.fluxTransaction)
await db.delete(schema.userFlux)
await db.delete(schema.session)
await db.delete(schema.account)
await db.delete(schema.user)
}
interface SeedUserOptions {
id: string
email?: string
balance: number
}
interface SessionUser {
id: string
email: string
emailVerified?: boolean
name?: string
}
export type Harness = Awaited<ReturnType<typeof startVerificationContext>>
/**
* Boots a Hono app with the same wiring as production for a verification
* scenario.
*
* Use when:
* - You need to assert a full user path (HTTP -> route -> service -> DB / ledger)
* rather than a unit-level code path
* - You want real `createFluxService` + `createBillingService` against a real
* in-memory Postgres (PGlite), with auth / OIDC / WebSocket / OTel stubbed
*
* Expects:
* - No external network. The mock router never opens sockets so
* pre-flight-rejecting cases never reach it; tests that actually need an
* upstream LLM response must stub `fetch` themselves
*
* Returns:
* - A `Harness` value with the mounted app, drizzle handle, and helpers to
* set a session user, seed flux balance, override config keys, and inspect
* the in-memory Redis store
*/
interface HarnessOptions {
/**
* Comma-separated email allowlist baked into `adminGuard`. Must be set at
* boot time — the guard parses this once at route construction, so flipping
* `ctx.env.ADMIN_EMAILS` mid-test has no effect. Tests that need to swap
* admins should boot a fresh context.
*/
adminEmails?: string
}
export async function startVerificationContext(opts: HarnessOptions = {}) {
const db = await getSharedDb()
await resetDataRows(db)
let activeSession: { user: any, session: any } | null = null
const auth: any = {
api: {
getSession: vi.fn(async () => activeSession),
getOAuthServerConfig: vi.fn(async () => ({})),
getOpenIdConfig: vi.fn(async () => ({})),
},
handler: vi.fn(async () => new Response('not-found', { status: 404 })),
}
const configStore: Record<string, any> = {
FLUX_PER_REQUEST: 1,
INITIAL_USER_FLUX: 0,
AUTH_RATE_LIMIT_MAX: 1000,
AUTH_RATE_LIMIT_WINDOW_SEC: 60,
FLUX_PER_1K_CHARS_TTS: 2,
TTS_DEBT_TTL_SECONDS: 86400,
}
const configKV: any = {
get: vi.fn(async (key: string) => configStore[key]),
getOrThrow: vi.fn(async (key: string) => {
if (configStore[key] === undefined)
throw new Error(`Config key "${key}" is not set`)
return configStore[key]
}),
getOptional: vi.fn(async (key: string) => (configStore[key] ?? null)),
set: vi.fn(async (key: string, value: any) => {
configStore[key] = value
}),
}
const redisStore = new Map<string, string>()
const redisSubscriber = {
on: vi.fn(),
subscribe: vi.fn(async () => 1),
unsubscribe: vi.fn(async () => 0),
quit: vi.fn(async () => 'OK'),
}
const redis: any = {
get: vi.fn(async (key: string) => redisStore.get(key) ?? null),
getBuffer: vi.fn(async (key: string) => {
const v = redisStore.get(key)
return v ? Buffer.from(v, 'utf8') : null
}),
set: vi.fn(async (key: string, value: any) => {
redisStore.set(key, String(value))
return 'OK'
}),
del: vi.fn(async (key: string) => (redisStore.delete(key) ? 1 : 0)),
incrby: vi.fn(async (key: string, by: number) => {
const next = (Number.parseInt(redisStore.get(key) ?? '0', 10) || 0) + by
redisStore.set(key, String(next))
return next
}),
expire: vi.fn(async () => 1),
duplicate: vi.fn(() => redisSubscriber),
publish: vi.fn(async () => 0),
}
const fluxService = createFluxService(db, redis, configKV)
const billingService = createBillingService(db, redis, configKV)
const adminFluxGrantsService = createAdminFluxGrantsService({ db, billingService })
// NOTICE:
// Production wires 5 soft-delete handlers (stripe / flux / providers /
// characters / chats). The harness only wires `flux` so the verification
// for "balance soft-deleted + ledger preserved" can run without dragging
// in stripe SDK, character / chat / provider services. Tests covering the
// other 4 handlers should opt in via a future option flag rather than
// widening the default wiring.
const userDeletionService = createUserDeletionService()
userDeletionService.register({
name: 'flux',
priority: 20,
softDelete: ({ userId }) => fluxService.deleteAllForUser(userId),
})
// NOTICE:
// The Proxy returns a fresh vi.fn() for every property access. Stand-in for
// services this verification doesn't touch (chat, characters, providers,
// stripe, admin-flux-grants, user-deletion, ttsMeter). If a test exercises
// one of these and starts getting `undefined is not a function` errors,
// wire in a real instance instead of widening this stub.
const stub: any = new Proxy({}, { get: () => vi.fn(async () => undefined) })
const env: any = {
API_SERVER_URL: 'http://localhost:3000',
ADMIN_EMAILS: opts.adminEmails ?? '',
OTEL_SERVICE_NAME: 'airi-server-test',
ADDITIONAL_TRUSTED_ORIGINS: '',
HOST: '127.0.0.1',
PORT: 0,
}
const { app } = await buildApp({
auth,
db,
characterService: stub,
chatService: stub,
providerService: stub,
fluxService,
fluxTransactionService: stub,
stripeService: stub,
billingService,
adminFluxGrantsService,
ttsMeter: stub,
requestLogService: { logRequest: vi.fn(async () => undefined) } as any,
configKV,
redis,
env,
otel: null,
userDeletionService,
llmRouter: {
route: vi.fn(async () => new Response('{}', { status: 200 })),
invalidateConfig: vi.fn(),
} as any,
posthog: null,
})
return {
app,
db,
schema,
redisStore,
configStore,
userDeletionService,
fluxService,
setSessionUser(user: SessionUser | null) {
activeSession = user
? {
user: {
id: user.id,
email: user.email,
name: user.name ?? user.id,
emailVerified: user.emailVerified ?? true,
createdAt: new Date(),
updatedAt: new Date(),
},
session: {
id: `sess-${user.id}`,
userId: user.id,
token: `tok-${user.id}`,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: new Date(Date.now() + 3600_000),
ipAddress: null,
userAgent: null,
},
}
: null
},
async seedUser(opts: SeedUserOptions) {
await db.insert(schema.user).values({
id: opts.id,
name: opts.id,
email: opts.email ?? `${opts.id}@example.com`,
emailVerified: true,
}).onConflictDoNothing()
await db.insert(schema.userFlux).values({
userId: opts.id,
flux: opts.balance,
}).onConflictDoNothing()
// NOTICE:
// Prime the Redis cache so `fluxService.getFlux()` reads the seeded
// balance directly instead of touching the DB-init path (which would
// create an `initial` flux_transaction row and skew ledger assertions).
redisStore.set(userFluxRedisKey(opts.id), String(opts.balance))
},
setConfig(kv: Record<string, any>) {
Object.assign(configStore, kv)
},
}
}