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

283 lines
11 KiB
TypeScript

import type { Database } from '../../../libs/db'
import { eq } from 'drizzle-orm'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../../libs/mock-db'
import { createCharacterService } from '../characters'
import { createChatService } from '../chats'
import { createFluxService } from '../flux'
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),
getOrThrow: vi.fn(async () => 0),
set: vi.fn(async () => {}),
} as any
}
describe('fluxService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('marks userFlux.deletedAt and invalidates Redis cache', async () => {
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 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'))
})
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 service = createFluxService(db, redis, fakeConfigKV())
await service.deleteAllForUser('u-flux-2')
const firstStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
// Second invocation: WHERE deletedAt IS NULL filters out the
// already-stamped row, so deletedAt does not change.
await service.deleteAllForUser('u-flux-2')
const secondStamp = (await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, 'u-flux-2') }))?.deletedAt
expect(secondStamp).toEqual(firstStamp)
})
})
describe('providerService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('marks every userProviderConfigs row owned by the user', async () => {
await db.insert(schema.user).values({ id: 'u-prov-1', name: 'P', email: 'p@example.com' })
await db.insert(schema.userProviderConfigs).values([
{ ownerId: 'u-prov-1', definitionId: 'openai', name: 'a' },
{ ownerId: 'u-prov-1', definitionId: 'anthropic', name: 'b' },
])
const service = createProviderService(db)
await service.deleteAllForUser('u-prov-1')
const rows = await db.query.userProviderConfigs.findMany({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-1') })
expect(rows).toHaveLength(2)
rows.forEach(r => expect(r.deletedAt).toBeInstanceOf(Date))
})
it('does not touch other users rows', async () => {
await db.insert(schema.user).values({ id: 'u-prov-other', name: 'O', email: 'o@example.com' })
await db.insert(schema.userProviderConfigs).values({ ownerId: 'u-prov-other', definitionId: 'openai', name: 'kept' })
const service = createProviderService(db)
await service.deleteAllForUser('u-prov-1')
const otherRow = await db.query.userProviderConfigs.findFirst({ where: eq(schema.userProviderConfigs.ownerId, 'u-prov-other') })
expect(otherRow?.deletedAt).toBeNull()
})
})
describe('characterService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('soft-deletes characters where the user is owner OR creator', async () => {
await db.insert(schema.user).values([
{ id: 'u-char-1', name: 'C1', email: 'c1@example.com' },
{ id: 'u-char-2', name: 'C2', email: 'c2@example.com' },
])
await db.insert(schema.character).values([
{ id: 'char-owner', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-1', characterId: 'cid-1' },
{ id: 'char-creator', version: '1', coverUrl: '', creatorId: 'u-char-1', ownerId: 'u-char-2', characterId: 'cid-2' },
{ id: 'char-other', version: '1', coverUrl: '', creatorId: 'u-char-2', ownerId: 'u-char-2', characterId: 'cid-3' },
])
const service = createCharacterService(db)
await service.deleteAllForUser('u-char-1')
const owner = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-owner') })
const creator = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-creator') })
const other = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-other') })
expect(owner?.deletedAt).toBeInstanceOf(Date)
expect(creator?.deletedAt).toBeInstanceOf(Date)
expect(other?.deletedAt).toBeNull()
})
it('soft-deletes the user likes and bookmarks', async () => {
await db.insert(schema.user).values({ id: 'u-char-3', name: 'C3', email: 'c3@example.com' })
await db.insert(schema.character).values({
id: 'char-z',
version: '1',
coverUrl: '',
creatorId: 'u-char-3',
ownerId: 'u-char-3',
characterId: 'cid-z',
})
await db.insert(schema.characterLikes).values({ userId: 'u-char-3', characterId: 'char-z' })
await db.insert(schema.characterBookmarks).values({ userId: 'u-char-3', characterId: 'char-z' })
const service = createCharacterService(db)
await service.deleteAllForUser('u-char-3')
const like = await db.query.characterLikes.findFirst({ where: eq(schema.characterLikes.userId, 'u-char-3') })
const bookmark = await db.query.characterBookmarks.findFirst({ where: eq(schema.characterBookmarks.userId, 'u-char-3') })
expect(like?.deletedAt).toBeInstanceOf(Date)
expect(bookmark?.deletedAt).toBeInstanceOf(Date)
})
})
describe('chatService.deleteAllForUser', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
it('soft-deletes chats the user is a member of', async () => {
await db.insert(schema.user).values({ id: 'u-chat-1', name: 'C', email: 'chat@example.com' })
await db.insert(schema.chats).values([
{ id: 'chat-mine', type: 'private', title: 'mine' },
{ id: 'chat-other', type: 'private', title: 'other' },
])
await db.insert(schema.chatMembers).values({ chatId: 'chat-mine', memberType: 'user', userId: 'u-chat-1' })
const service = createChatService(db)
await service.deleteAllForUser('u-chat-1')
const mine = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-mine') })
const other = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-other') })
expect(mine?.deletedAt).toBeInstanceOf(Date)
expect(other?.deletedAt).toBeNull()
})
it('drops chat_members for shared (group/channel) chats but keeps the chat alive', async () => {
// Two users in a shared group chat. When user A is deleted, the chat
// row must survive for user B; only A's chat_members row goes.
await db.insert(schema.user).values([
{ id: 'u-grp-a', name: 'A', email: 'grpa@example.com' },
{ id: 'u-grp-b', name: 'B', email: 'grpb@example.com' },
])
await db.insert(schema.chats).values({ id: 'chat-grp', type: 'group', title: 'team' })
await db.insert(schema.chatMembers).values([
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-a' },
{ chatId: 'chat-grp', memberType: 'user', userId: 'u-grp-b' },
])
const service = createChatService(db)
await service.deleteAllForUser('u-grp-a')
const chatRow = await db.query.chats.findFirst({ where: eq(schema.chats.id, 'chat-grp') })
expect(chatRow?.deletedAt).toBeNull() // chat survives
const remainingMembers = await db.query.chatMembers.findMany({ where: eq(schema.chatMembers.chatId, 'chat-grp') })
expect(remainingMembers).toHaveLength(1)
expect(remainingMembers[0]?.userId).toBe('u-grp-b')
})
it('preserves the user messages inside group chats so other members keep conversation context', async () => {
// Anonymization-by-design: in a group chat, user A's messages must NOT
// be soft-deleted on account deletion — that would corrupt B's history.
// The senderId stays as the (now-orphan) user.id string; the UI renders
// it as "Deleted User" once it cannot resolve the id to a real user.
await db.insert(schema.user).values([
{ id: 'u-anon-a', name: 'A', email: 'anona@example.com' },
{ id: 'u-anon-b', name: 'B', email: 'anonb@example.com' },
])
await db.insert(schema.chats).values({ id: 'chat-anon-grp', type: 'group', title: 'team' })
await db.insert(schema.chatMembers).values([
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-a' },
{ chatId: 'chat-anon-grp', memberType: 'user', userId: 'u-anon-b' },
])
await db.insert(schema.messages).values([
{ id: 'm-a-1', chatId: 'chat-anon-grp', senderId: 'u-anon-a', role: 'user', content: 'hi from A', mediaIds: [], stickerIds: [] },
{ id: 'm-b-1', chatId: 'chat-anon-grp', senderId: 'u-anon-b', role: 'user', content: 'hi from B', mediaIds: [], stickerIds: [] },
])
const service = createChatService(db)
await service.deleteAllForUser('u-anon-a')
// A's message stays alive; senderId still points at the now-orphan user.id string.
const aMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-a-1') })
expect(aMsg?.deletedAt).toBeNull()
expect(aMsg?.senderId).toBe('u-anon-a')
expect(aMsg?.content).toBe('hi from A')
// B's message obviously untouched.
const bMsg = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'm-b-1') })
expect(bMsg?.deletedAt).toBeNull()
})
it('soft-deletes messages the user sent in private/bot chats', async () => {
await db.insert(schema.user).values({ id: 'u-chat-2', name: 'M', email: 'msg@example.com' })
await db.insert(schema.chats).values({ id: 'chat-msg', type: 'private', title: 't' })
await db.insert(schema.chatMembers).values({ chatId: 'chat-msg', memberType: 'user', userId: 'u-chat-2' })
await db.insert(schema.messages).values([
{
id: 'msg-mine',
chatId: 'chat-msg',
senderId: 'u-chat-2',
role: 'user',
content: 'hi',
mediaIds: [],
stickerIds: [],
},
{
id: 'msg-other',
chatId: 'chat-msg',
senderId: 'someone-else',
role: 'assistant',
content: 'hello',
mediaIds: [],
stickerIds: [],
},
])
const service = createChatService(db)
await service.deleteAllForUser('u-chat-2')
const mine = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-mine') })
const other = await db.query.messages.findFirst({ where: eq(schema.messages.id, 'msg-other') })
expect(mine?.deletedAt).toBeInstanceOf(Date)
expect(other?.deletedAt).toBeNull()
})
})