Files
moeka-project/apps/server/src/services/domain/user-deletion/index.test.ts
T
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

122 lines
4.1 KiB
TypeScript

import type { UserDeletionHandler } from './types'
import { describe, expect, it, vi } from 'vitest'
import { createUserDeletionService } from './index'
function makeHandler(name: string, priority: number, body?: () => Promise<void> | void): UserDeletionHandler {
return {
name,
priority,
softDelete: vi.fn(async () => {
await body?.()
}),
}
}
describe('createUserDeletionService', () => {
describe('register', () => {
it('rejects duplicate handler names', () => {
const service = createUserDeletionService()
service.register(makeHandler('flux', 20))
expect(() => service.register(makeHandler('flux', 30))).toThrow(/Duplicate user-deletion handler name: flux/)
})
it('keeps handlers in ascending priority regardless of registration order', async () => {
const service = createUserDeletionService()
const calls: string[] = []
service.register(makeHandler('characters', 30, () => {
calls.push('characters')
}))
service.register(makeHandler('stripe', 10, () => {
calls.push('stripe')
}))
service.register(makeHandler('flux', 20, () => {
calls.push('flux')
}))
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
// @example
// register order: characters(30) -> stripe(10) -> flux(20)
// execution order: stripe(10) -> flux(20) -> characters(30)
expect(calls).toEqual(['stripe', 'flux', 'characters'])
})
})
describe('softDeleteAll', () => {
it('passes the user id and reason to every handler', async () => {
const service = createUserDeletionService()
const a = makeHandler('a', 10)
const b = makeHandler('b', 20)
service.register(a)
service.register(b)
await service.softDeleteAll({ userId: 'user-xyz', reason: 'admin' })
expect(a.softDelete).toHaveBeenCalledTimes(1)
expect(a.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
expect(b.softDelete).toHaveBeenCalledTimes(1)
expect(b.softDelete).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-xyz', reason: 'admin' }))
})
it('aborts on first handler error and skips later handlers', async () => {
const service = createUserDeletionService()
const earlyOk = makeHandler('a', 10)
const failing = makeHandler('b', 20, () => {
throw new Error('stripe API down')
})
const lateNeverRuns = makeHandler('c', 30)
service.register(earlyOk)
service.register(failing)
service.register(lateNeverRuns)
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' }))
.rejects
.toThrow('stripe API down')
expect(earlyOk.softDelete).toHaveBeenCalledTimes(1)
expect(failing.softDelete).toHaveBeenCalledTimes(1)
expect(lateNeverRuns.softDelete).not.toHaveBeenCalled()
})
it('runs handlers serially (next starts only after previous resolves)', async () => {
const service = createUserDeletionService()
const order: string[] = []
service.register({
name: 'slow',
priority: 10,
softDelete: async () => {
order.push('slow:start')
await new Promise(r => setTimeout(r, 5))
order.push('slow:end')
},
})
service.register({
name: 'fast',
priority: 20,
softDelete: async () => {
order.push('fast:start')
order.push('fast:end')
},
})
await service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })
// @example
// serial execution: slow:start -> slow:end -> fast:start -> fast:end
// (NOT slow:start -> fast:start -> slow:end -> fast:end which would
// indicate parallelism)
expect(order).toEqual(['slow:start', 'slow:end', 'fast:start', 'fast:end'])
})
it('runs no handlers gracefully when registry is empty', async () => {
const service = createUserDeletionService()
await expect(service.softDeleteAll({ userId: 'u1', reason: 'user-requested' })).resolves.toBeUndefined()
})
})
})