mirror of
https://github.com/moeru-ai/airi.git
synced 2026-05-19 00:01:34 +00:00
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.
265 lines
8.8 KiB
TypeScript
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)
|
|
},
|
|
}
|
|
}
|