import { BadRequestException, ConflictException, ForbiddenException, NotFoundException, } from '@nestjs/common'; import { AiChatService } from './ai-chat.service'; const authenticatedUser = { id: 7, username: 'tester', permissions: ['ai:chat:use'], isSuperAdmin: false, }; function createService( conversationOverrides: Record = {}, messageOverrides: Record = {}, ) { const conversations = { findOne: jest.fn(), find: jest.fn(), create: jest.fn((value) => value), save: jest.fn(async (value) => ({ id: 1, ...value })), remove: jest.fn(), ...conversationOverrides, }; const messages = { exists: jest.fn().mockResolvedValue(false), createQueryBuilder: jest.fn(), ...messageOverrides, }; const reviewService = { createReview: jest.fn(), expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), findOwned: jest.fn(), findPendingByAssistantMessage: jest.fn(), submitSection: jest.fn(), submitGroup: jest.fn(), submitAll: jest.fn(), parseSections: jest.fn((json: string) => JSON.parse(json)), serialize: jest.fn((value) => value), }; const service = new AiChatService( conversations as never, messages as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, { createForm: jest.fn(), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), serialize: jest.fn((value) => value), } as never, reviewService as never, { createChart: jest.fn(), serialize: jest.fn((value) => value), } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, ); return { service, conversations, reviewService }; } describe('AiChatService', () => { it('按 userId 查询会话,无法借 id 访问其他用户会话', async () => { const { service, conversations } = createService({ findOne: jest.fn().mockResolvedValue(null), }); await expect(service.getMessages(7, 99)).rejects.toBeInstanceOf(NotFoundException); expect(conversations.findOne).toHaveBeenCalledWith({ where: { id: 99, userId: 7 } }); }); it('生成中的会话禁止删除', async () => { const entity = { id: 2, userId: 7 }; const { service, conversations } = createService({ findOne: jest.fn().mockResolvedValue(entity), }); (service as unknown as { activeConversations: Set }).activeConversations.add(2); await expect(service.deleteConversation(7, 2)).rejects.toBeInstanceOf(ConflictException); expect(conversations.remove).not.toHaveBeenCalled(); }); it('批量删除全部会话并清理附件', async () => { const entities = [ { id: 2, userId: 7 }, { id: 3, userId: 7 }, ]; const getRawMany = jest.fn().mockResolvedValue([{ id: 10 }, { id: 11 }]); const queryBuilder = { innerJoin: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), getRawMany, }; const { service, conversations } = createService( { find: jest.fn().mockResolvedValue(entities) }, { createQueryBuilder: jest.fn().mockReturnValue(queryBuilder) }, ); const removeOrphans = jest.fn().mockResolvedValue(undefined); ( service as unknown as { attachmentService: { removeOrphans: jest.Mock }; } ).attachmentService.removeOrphans = removeOrphans; await expect(service.deleteAllConversations(7)).resolves.toBe(2); expect(conversations.find).toHaveBeenCalledWith({ where: { userId: 7 } }); expect(queryBuilder.where).toHaveBeenCalledWith('message.conversation_id IN (:...ids)', { ids: [2, 3], }); expect(conversations.remove).toHaveBeenCalledWith(entities); expect(removeOrphans).toHaveBeenCalledWith(7, [10, 11]); }); it('存在生成中的会话时禁止批量删除', async () => { const entities = [ { id: 2, userId: 7 }, { id: 3, userId: 7 }, ]; const { service, conversations } = createService({ find: jest.fn().mockResolvedValue(entities), }); (service as unknown as { activeConversations: Set }).activeConversations.add(2); await expect(service.deleteAllConversations(7)).rejects.toBeInstanceOf(ConflictException); expect(conversations.remove).not.toHaveBeenCalled(); }); it('没有会话时批量删除返回 0', async () => { const { service, conversations } = createService({ find: jest.fn().mockResolvedValue([]), }); await expect(service.deleteAllConversations(7)).resolves.toBe(0); expect(conversations.remove).not.toHaveBeenCalled(); }); it('并发获取同一会话时只允许一个请求进入生成流程', async () => { let resolveExists!: (value: boolean) => void; const exists = jest.fn( () => new Promise((resolve) => { resolveExists = resolve; }), ); const { service } = createService(); (service as unknown as { messages: { exists: typeof exists } }).messages.exists = exists; const acquire = ( service as unknown as { acquireConversation(id: number): Promise } ).acquireConversation.bind(service); const first = acquire(5); await expect(acquire(5)).rejects.toBeInstanceOf(ConflictException); resolveExists(false); await expect(first).resolves.toBeUndefined(); }); it('工具摘要脱敏并限制长度', () => { const { service } = createService(); const summarize = (service as unknown as { summarize(value: unknown): string }).summarize.bind( service, ); const summary = summarize({ phone: '13800138000', idCard: '11010519491231002X', note: `联系电话 13900139000 ${'x'.repeat(3000)}`, apiKey: 'sk-sensitive-value', }); expect(summary).not.toContain('13800138000'); expect(summary).not.toContain('13900139000'); expect(summary).not.toContain('11010519491231002X'); expect(summary).not.toContain('sk-sensitive-value'); expect(summary.length).toBeLessThanOrEqual(2000); }); it('超大附件文本完整进入模型,不截断', async () => { const { service } = createService(); (service as unknown as { attachmentService: { toModelParts: jest.Mock } }).attachmentService = { toModelParts: jest .fn() .mockResolvedValue([ { attachment: { id: 1, originalName: 'big.xlsx' }, text: 'x'.repeat(120000) }, ]), }; const build = ( service as unknown as { buildUserContent( text: string, attachments: unknown[], supportsVision: boolean, ): Promise; } ).buildUserContent.bind(service); const result = await build('请看这个文件', [{ id: 1 }], false); expect(typeof result).toBe('string'); expect(result as string).toContain('附件:big.xlsx'); expect((result as string).length).toBeGreaterThan(120000); }); it('大 Excel 附件全文进入模型,不再生成概览', async () => { const { service } = createService(); (service as unknown as { attachmentService: unknown }).attachmentService = { toModelParts: jest.fn().mockResolvedValue([ { attachment: { id: 1, originalName: 'big.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }, text: 'x'.repeat(30000), }, ]), }; const build = ( service as unknown as { buildUserContent( text: string, attachments: unknown[], supportsVision: boolean, ): Promise; } ).buildUserContent.bind(service); const result = await build('请看这个文件', [{ id: 1 }], false); expect(result as string).toContain('附件:big.xlsx'); expect(result as string).toContain('x'.repeat(30000)); }); it.each([ { abort: false, expectedStatus: 'failed', expectedCode: 'UPSTREAM_ERROR' }, { abort: true, expectedStatus: 'cancelled', expectedCode: 'CLIENT_ABORTED' }, ])( '流中断后保存已生成内容和 $expectedStatus 状态', async ({ abort, expectedStatus, expectedCode }) => { const conversation = { id: 3, userId: 7, title: '测试', lockedSkillKey: null, lastMessageAt: null, }; const assistant = { id: 12, conversationId: 3, role: 'assistant', content: '', reasoningContent: null, status: 'pending', errorCode: null, }; const messageSave = jest.fn(async (value) => value); const messages = { exists: jest.fn().mockResolvedValue(false), find: jest.fn().mockResolvedValue([]), save: messageSave, }; const manager = { create: jest.fn((_entity, value) => value), save: jest .fn() .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '查询' }) .mockResolvedValueOnce(assistant), update: jest.fn(), }; const abortController = new AbortController(); const modelStream = { stream: async function* () { yield { type: 'content' as const, delta: '部分回答' }; if (abort) { abortController.abort(new Error('client disconnected')); yield { type: 'complete' as const, toolCalls: [] }; return; } throw new Error('upstream failed'); }, }; const service = new AiChatService( { findOne: jest.fn().mockResolvedValue(conversation) } as never, messages as never, { save: jest.fn() } as never, { transaction: jest.fn(async (callback) => callback(manager)) } as never, { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, { listAvailable: jest.fn().mockReturnValue([]) } as never, modelStream as never, { requireReadyOwned: jest.fn().mockResolvedValue([]), toModelParts: jest.fn().mockResolvedValue([]), serialize: jest.fn((value) => value), } as never, { createForm: jest.fn(), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), serialize: jest.fn((value) => value), } as never, { createReview: jest.fn(), expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, { createChart: jest.fn(), serialize: jest.fn((value) => value), } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; const run = service.streamMessage( authenticatedUser as never, 3, { message: '查询', attachmentIds: [], skillKey: null, clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', }, abortController.signal, (event, data) => emitted.push({ event, data }), jest.fn(), ); if (abort) await expect(run).resolves.toBeUndefined(); else await expect(run).rejects.toThrow('upstream failed'); expect(messageSave).toHaveBeenCalledWith( expect.objectContaining({ id: 12, content: '部分回答', status: expectedStatus, errorCode: expectedCode, }), ); expect(emitted.some(({ event }) => event === 'content.delta')).toBe(true); expect(emitted.some(({ event }) => event === 'message.cancelled')).toBe(abort); }, ); it('普通对话中模型直接调用 create_student 被拒绝', async () => { const { service } = createService(); const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => value), find: jest.fn().mockResolvedValue([]), }; (service as unknown as { toolRuns: typeof toolRuns }).toolRuns = toolRuns; const emitted: Array<{ event: string }> = []; const deny = ( service as unknown as { denyWriteTool( messageId: number, call: { id: string }, emit: (event: string, data: Record) => void, ): Promise; } ).denyWriteTool.bind(service); const payload = await deny(12, { id: 'call-1' }, (event) => emitted.push({ event })); expect(JSON.parse(payload)).toEqual({ status: 'failed', error: '该操作需要表单确认' }); expect(emitted).toEqual([{ event: 'tool.failed' }]); }); it('render_form 生成的表单在生成结束保存时保留在消息 metadata 中', async () => { const conversation = { id: 3, userId: 7, title: '测试', lockedSkillKey: null, lastMessageAt: null, }; const assistant = { id: 12, conversationId: 3, role: 'assistant', content: '', reasoningContent: null, status: 'pending', errorCode: null, replyToMessageId: 11, metadata: {}, }; const formShape = { id: 'form-1', conversationId: 3, title: '新增学生', status: 'pending', fields: [], }; const messageSave = jest.fn(async (value) => value); const messages = { exists: jest.fn().mockResolvedValue(false), find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockImplementation((options?: unknown) => { const opts = options as { select?: { metadata?: boolean } } | undefined; if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiForm: formShape } }); return Promise.resolve(assistant); }), save: messageSave, }; const manager = { create: jest.fn((_entity, value) => value), save: jest .fn() .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '帮我新增一个学生', }) .mockResolvedValueOnce(assistant), update: jest.fn(), }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => value), find: jest.fn().mockResolvedValue([]), }; const modelStream = { stream: async function* () { yield { type: 'complete' as const, toolCalls: [ { id: 'call-1', name: 'render_form', arguments: JSON.stringify({ title: '新增学生', fields: [{ name: 'name', label: '姓名', type: 'input', required: true }], }), }, ], }; }, }; const service = new AiChatService( { findOne: jest.fn().mockResolvedValue(conversation) } as never, messages as never, toolRuns as never, { transaction: jest.fn(async (callback) => callback(manager)) } as never, { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, { listAvailable: jest.fn().mockReturnValue([]) } as never, modelStream as never, { requireReadyOwned: jest.fn().mockResolvedValue([]), toModelParts: jest.fn().mockResolvedValue([]), serialize: jest.fn((value) => value), } as never, { createForm: jest.fn().mockResolvedValue(formShape), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), serialize: jest.fn((value) => value), } as never, { createReview: jest.fn(), expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, { createChart: jest.fn(), serialize: jest.fn((value) => value), } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; await service.streamMessage( authenticatedUser as never, 3, { message: '帮我新增一个学生', attachmentIds: [], skillKey: null, clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', }, new AbortController().signal, (event, data) => emitted.push({ event, data }), jest.fn(), ); expect(emitted.some(({ event }) => event === 'ui.form')).toBe(true); expect(messageSave).toHaveBeenCalledWith( expect.objectContaining({ id: 12, metadata: expect.objectContaining({ a2uiForm: expect.objectContaining({ id: 'form-1' }), }), }), ); }); it('render_chart 生成的图表通过 ui.chart 推送并追加到消息 metadata', async () => { const conversation = { id: 3, userId: 7, title: '测试', lockedSkillKey: null, lastMessageAt: null, }; const assistant = { id: 12, conversationId: 3, role: 'assistant', content: '', reasoningContent: null, status: 'pending', errorCode: null, replyToMessageId: 11, metadata: {}, }; const chartShape = { id: 'chart-1', title: '各班级人数', chartType: 'bar', columns: [ { key: 'className', title: '班级' }, { key: 'count', title: '人数' }, ], rows: [ { className: '一班', count: 20 }, { className: '二班', count: 15 }, ], }; const messageSave = jest.fn(async (value) => value); const messages = { exists: jest.fn().mockResolvedValue(false), find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockImplementation((options?: unknown) => { const opts = options as { select?: { metadata?: boolean } } | undefined; if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiChart: [chartShape] } }); return Promise.resolve(assistant); }), save: messageSave, }; const manager = { create: jest.fn((_entity, value) => value), save: jest .fn() .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '画个柱状图' }) .mockResolvedValueOnce(assistant), update: jest.fn(), }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => value), find: jest.fn().mockResolvedValue([]), }; const modelStream = { stream: async function* () { yield { type: 'complete' as const, toolCalls: [ { id: 'call-1', name: 'render_chart', arguments: JSON.stringify({ title: '各班级人数', chartType: 'bar', columns: [ { key: 'className', title: '班级' }, { key: 'count', title: '人数' }, ], rows: [ { className: '一班', count: 20 }, { className: '二班', count: 15 }, ], }), }, ], }; }, }; const service = new AiChatService( { findOne: jest.fn().mockResolvedValue(conversation) } as never, messages as never, toolRuns as never, { transaction: jest.fn(async (callback) => callback(manager)) } as never, { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, { listAvailable: jest.fn().mockReturnValue([]) } as never, modelStream as never, { requireReadyOwned: jest.fn().mockResolvedValue([]), toModelParts: jest.fn().mockResolvedValue([]), serialize: jest.fn((value) => value), } as never, { createForm: jest.fn(), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), serialize: jest.fn((value) => value), } as never, { createReview: jest.fn(), expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, { createChart: jest.fn().mockResolvedValue(chartShape), serialize: jest.fn((value) => value), } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; await service.streamMessage( authenticatedUser as never, 3, { message: '画个柱状图', attachmentIds: [], skillKey: null, clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', }, new AbortController().signal, (event, data) => emitted.push({ event, data }), jest.fn(), ); expect(emitted.some(({ event }) => event === 'ui.chart')).toBe(true); expect(messageSave).toHaveBeenCalledWith( expect.objectContaining({ id: 12, metadata: expect.objectContaining({ a2uiChart: expect.arrayContaining([expect.objectContaining({ id: 'chart-1' })]), }), }), ); }); it('submitReview 无写入权限时拒绝批量导入', async () => { const conversation = { id: 3, userId: 7, title: '测试', lockedSkillKey: null, lastMessageAt: null, }; const review = { id: 'review-1', conversationId: 3, userId: 7, assistantMessageId: 12, title: '批量导入', summary: null, sectionsJson: JSON.stringify([ { key: 'students', title: '学生' }, { key: 'rooms', title: '宿舍' }, ]), status: 'pending', resultSummary: null, submittedAt: null, }; const messages = { exists: jest.fn().mockResolvedValue(false), find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue({ id: 12, metadata: {} }), save: jest.fn(async (value) => value), }; const manager = { create: jest.fn((_entity, value) => value), save: jest.fn().mockResolvedValue({ id: 11 }), update: jest.fn(), }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => value), find: jest.fn().mockResolvedValue([]), }; const submitAll = jest.fn(); const assertPermission = jest.fn().mockImplementation(() => { throw new ForbiddenException('权限不足:缺少权限码 student:create'); }); const service = new AiChatService( { findOne: jest.fn().mockResolvedValue(conversation) } as never, messages as never, toolRuns as never, { transaction: jest.fn(async (callback) => callback(manager)) } as never, { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, { listAvailable: jest.fn().mockReturnValue([]) } as never, { stream: jest.fn() } as never, { requireReadyOwned: jest.fn().mockResolvedValue([]), toModelParts: jest.fn().mockResolvedValue([]), serialize: jest.fn((value) => value), } as never, { createForm: jest.fn(), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), serialize: jest.fn((value) => value), } as never, { createReview: jest.fn(), expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn().mockResolvedValue(review), findPendingByAssistantMessage: jest.fn(), findOwned: jest.fn(), submitSection: jest.fn(), submitAll, parseSections: jest.fn((json: string) => JSON.parse(json)), serialize: jest.fn((value) => value), } as never, { createChart: jest.fn(), serialize: jest.fn((value) => value), } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission, canPermission: jest.fn() } as never, ); const emitted: Array<{ event: string }> = []; await expect( service.submitReview( authenticatedUser as never, 'review-1', { clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', reasoningEffort: null, } as never, new AbortController().signal, (event) => emitted.push({ event }), jest.fn(), ), ).rejects.toBeInstanceOf(ForbiddenException); expect(submitAll).not.toHaveBeenCalled(); expect(emitted).toHaveLength(0); }); it('submitReview 校验写入权限、先 onReady 再推 ui.review,并标记原卡片已提交', async () => { const conversation = { id: 3, userId: 7, title: '测试', lockedSkillKey: null, lastMessageAt: null, }; const assistant = { id: 12, conversationId: 3, role: 'assistant', content: '', reasoningContent: null, status: 'pending', errorCode: null, replyToMessageId: 11, metadata: { a2uiReview: { id: 'review-1', status: 'pending' } }, }; const review = { id: 'review-1', conversationId: 3, userId: 7, assistantMessageId: 12, title: '批量导入', summary: null, sectionsJson: JSON.stringify([{ key: 'students', title: '学生' }]), status: 'pending', resultSummary: null, submittedAt: null, }; const messageSave = jest.fn(async (value) => value); const messages = { exists: jest.fn().mockResolvedValue(false), find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockImplementation((options?: unknown) => { const opts = options as { select?: { metadata?: boolean } } | undefined; if (opts?.select?.metadata) { return Promise.resolve({ metadata: { a2uiReview: { id: 'review-1', status: 'submitted' } }, }); } return Promise.resolve(assistant); }), save: messageSave, }; const manager = { create: jest.fn((_entity, value) => value), save: jest .fn() .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '已确认导入' }) .mockResolvedValueOnce({ id: 13, conversationId: 3, role: 'assistant' }), update: jest.fn(), }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => value), find: jest.fn().mockResolvedValue([]), }; const modelStream = { stream: async function* () { yield { type: 'complete' as const, toolCalls: [] }; }, }; const submitAll = jest.fn(async () => { review.status = 'submitted'; review.submittedAt = new Date(); return { review, result: { message: '成功导入学生 1 人、宿舍 0 间、换宿 0 条、入住 0 条;跳过 0 条', students: { created: 1, skipped: 0, issues: [] }, rooms: { created: 0, skipped: 0, issues: [] }, transfers: { completed: 0, skipped: 0, issues: [] }, checkins: { completed: 0, skipped: 0, issues: [] }, }, }; }); const assertPermission = jest.fn(); const service = new AiChatService( { findOne: jest.fn().mockResolvedValue(conversation) } as never, messages as never, toolRuns as never, { transaction: jest.fn(async (callback) => callback(manager)) } as never, { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, { listAvailable: jest.fn().mockReturnValue([]) } as never, modelStream as never, { requireReadyOwned: jest.fn().mockResolvedValue([]), toModelParts: jest.fn().mockResolvedValue([]), serialize: jest.fn((value) => value), } as never, { createForm: jest.fn(), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), serialize: jest.fn((value) => value), } as never, { createReview: jest.fn(), expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn().mockResolvedValue(review), findPendingByAssistantMessage: jest.fn(), findOwned: jest.fn(), submitSection: jest.fn(), submitAll, parseSections: jest.fn((json: string) => JSON.parse(json)), serialize: jest.fn((value) => value), } as never, { createChart: jest.fn(), serialize: jest.fn((value) => value), } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission, canPermission: jest.fn() } as never, ); const order: string[] = []; const emitted: Array<{ event: string; data: Record }> = []; await service.submitReview( authenticatedUser as never, 'review-1', { clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', reasoningEffort: null, } as never, new AbortController().signal, (event, data) => { order.push(`emit:${event}`); emitted.push({ event, data }); }, () => order.push('onReady'), ); expect(assertPermission).toHaveBeenCalledWith(expect.anything(), 'student:create'); expect(submitAll).toHaveBeenCalledTimes(1); expect(emitted[0]).toMatchObject({ event: 'ui.review', data: { messageId: 12, review: expect.objectContaining({ id: 'review-1' }) }, }); expect(order.indexOf('onReady')).toBeLessThan(order.indexOf('emit:ui.review')); expect(messageSave).toHaveBeenCalledWith( expect.objectContaining({ id: 12, metadata: expect.objectContaining({ a2uiReview: expect.objectContaining({ status: 'submitted' }), }), }), ); }); it('confirmReviewStep 只校验当前分表权限并返回更新后的预览', async () => { const { service, reviewService } = createService(); const review = { id: 'review-1', conversationId: 3, userId: 7, assistantMessageId: 12, title: '批量导入', summary: null, sectionsJson: JSON.stringify([ { key: 'students', title: '学生' }, { key: 'rooms', title: '宿舍' }, ]), status: 'pending', resultSummary: null, submittedAt: null, }; const updated = { ...review, sectionsJson: JSON.stringify([ { key: 'students', title: '学生', status: 'submitted' }, { key: 'rooms', title: '宿舍', status: 'pending' }, ]), }; (service as unknown as { messages: unknown }).messages = { findOne: jest.fn().mockResolvedValue({ id: 12, conversationId: 3, metadata: { a2uiReview: { id: 'review-1', status: 'pending' } }, }), save: jest.fn(async (value) => value), }; reviewService.findOwned.mockResolvedValue(review); reviewService.submitSection.mockResolvedValue({ review: updated, result: { created: 1, skipped: 0, issues: [] }, message: '成功导入学生 1 人', }); const data = await service.confirmReviewStep( authenticatedUser as never, 'review-1', 'students', ); expect(reviewService.findOwned).toHaveBeenCalledWith('review-1', 7); expect(reviewService.submitSection).toHaveBeenCalledWith('review-1', 7, 'students'); expect(data).toMatchObject({ id: 'review-1' }); }); it('confirmReviewStep 确认导入后写入操作日志', async () => { const { service, reviewService } = createService(); const review = { id: 'review-1', conversationId: 3, userId: 7, assistantMessageId: 12, title: '批量导入', summary: null, sectionsJson: JSON.stringify([{ key: 'students', title: '学生' }]), status: 'pending', resultSummary: null, submittedAt: null, }; const updated = { ...review, sectionsJson: JSON.stringify([ { key: 'students', title: '学生', status: 'submitted' }, ]), }; const opLog = { log: jest.fn().mockResolvedValue(undefined) }; (service as unknown as { opLog: unknown }).opLog = opLog; (service as unknown as { messages: unknown }).messages = { findOne: jest.fn().mockResolvedValue({ id: 12, conversationId: 3, metadata: { a2uiReview: { id: 'review-1', status: 'pending' } }, }), save: jest.fn(async (value) => value), }; reviewService.findOwned.mockResolvedValue(review); reviewService.submitSection.mockResolvedValue({ review: updated, result: { created: 1, skipped: 0, issues: [] }, message: '成功导入学生 1 人,跳过 0 条', }); await service.confirmReviewStep(authenticatedUser as never, 'review-1', 'students'); expect(opLog.log).toHaveBeenCalledWith( expect.objectContaining({ userId: 7, username: 'tester', module: '批量导入', action: '确认导入分表', detail: expect.stringContaining('成功导入学生 1 人'), targetType: 'ai_review', status: 'success', }), ); }); it('confirmReviewStep / confirmReviewGroup 对已失效预览返回 409', async () => { const { service, reviewService } = createService(); const review = { id: 'review-1', conversationId: 3, userId: 7, assistantMessageId: 12, title: '批量导入', summary: null, sectionsJson: '[]', status: 'expired', resultSummary: null, submittedAt: null, }; reviewService.findOwned.mockResolvedValue(review); await expect( service.confirmReviewStep(authenticatedUser as never, 'review-1', 'students'), ).rejects.toMatchObject({ message: expect.stringContaining('已失效') }); await expect( service.confirmReviewGroup(authenticatedUser as never, 'review-1', 'students'), ).rejects.toMatchObject({ message: expect.stringContaining('已失效') }); expect(reviewService.submitSection).not.toHaveBeenCalled(); expect(reviewService.submitGroup).not.toHaveBeenCalled(); }); it('confirmReviewGroup 只校验当前类型权限并原位更新预览卡', async () => { const { service, reviewService } = createService(); const review = { id: 'review-1', conversationId: 3, userId: 7, assistantMessageId: 12, title: '批量导入', summary: null, sectionsJson: JSON.stringify([ { key: 'checkins_a', type: 'checkins', title: '入住A' }, { key: 'checkins_b', type: 'checkins', title: '入住B' }, ]), status: 'pending', resultSummary: null, submittedAt: null, }; const updated = { ...review, sectionsJson: JSON.stringify([ { key: 'checkins_a', type: 'checkins', title: '入住A', status: 'submitted' }, { key: 'checkins_b', type: 'checkins', title: '入住B', status: 'submitted' }, ]), status: 'submitted', }; (service as unknown as { messages: unknown }).messages = { findOne: jest.fn().mockResolvedValue({ id: 12, conversationId: 3, metadata: { a2uiReview: { id: 'review-1', status: 'pending' } }, }), save: jest.fn(async (value) => value), }; reviewService.findOwned.mockResolvedValue(review); reviewService.submitGroup.mockResolvedValue({ review: updated }); const data = await service.confirmReviewGroup( authenticatedUser as never, 'review-1', 'checkins', ); expect(reviewService.findOwned).toHaveBeenCalledWith('review-1', 7); expect(reviewService.submitGroup).toHaveBeenCalledWith('review-1', 7, 'checkins'); expect(data).toMatchObject({ id: 'review-1', status: 'submitted' }); }); it('删除用户消息时连同其 AI 回答一起删除并更新会话时间', async () => { const conversation = { id: 3, userId: 7, title: '新对话' }; const execute = jest.fn().mockResolvedValue(undefined); const queryBuilder = { innerJoin: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue([{ id: 30 }, { id: 31 }]), delete: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), execute, }; const lastMessageAt = new Date('2026-08-04T10:00:00.000Z'); const messages = { findOne: jest .fn() .mockResolvedValueOnce({ id: 10, conversationId: 3, role: 'user' }) .mockResolvedValueOnce({ createdAt: lastMessageAt }), find: jest.fn().mockResolvedValue([{ id: 11 }]), createQueryBuilder: jest.fn().mockReturnValue(queryBuilder), delete: jest.fn().mockResolvedValue({ affected: 2 }), }; const conversations = { findOne: jest.fn().mockResolvedValue(conversation), update: jest.fn().mockResolvedValue(undefined), }; const removeOrphans = jest.fn().mockResolvedValue(undefined); const service = new AiChatService( conversations as never, messages as never, {} as never, {} as never, {} as never, {} as never, {} as never, { removeOrphans } as never, { createForm: jest.fn(), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), serialize: jest.fn((value) => value), } as never, { createReview: jest.fn(), expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, { createChart: jest.fn(), serialize: jest.fn((value) => value), } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, ); await expect(service.deleteMessage(7, 3, 10)).resolves.toEqual({ deletedIds: [10, 11] }); expect(messages.delete).toHaveBeenCalledWith([10, 11]); expect(execute).toHaveBeenCalled(); expect(removeOrphans).toHaveBeenCalledWith(7, [30, 31]); expect(conversations.update).toHaveBeenCalledWith({ id: 3, userId: 7 }, { lastMessageAt }); }); it('生成中的会话禁止删除单条消息', async () => { const service = new AiChatService( { findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }) } as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, { createForm: jest.fn(), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), serialize: jest.fn((value) => value), } as never, { createReview: jest.fn(), expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, { createChart: jest.fn(), serialize: jest.fn((value) => value), } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, ); (service as unknown as { activeConversations: Set }).activeConversations.add(3); await expect(service.deleteMessage(7, 3, 10)).rejects.toBeInstanceOf(ConflictException); }); it('编辑用户消息后截断后续消息并重新生成回答', async () => { const conversation = { id: 3, userId: 7, title: '旧问题' }; const target = { id: 10, conversationId: 3, role: 'user', status: 'completed', content: '旧问题', metadata: null, attachments: [], }; const assistant = { id: 13, conversationId: 3, role: 'assistant' }; const manager = { update: jest.fn().mockResolvedValue(undefined), find: jest.fn().mockResolvedValue([{ id: 11 }, { id: 12 }]), createQueryBuilder: jest.fn().mockReturnValue({ innerJoin: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue([]), delete: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue(undefined), }), delete: jest.fn().mockResolvedValue({ affected: 2 }), create: jest.fn((_entity, value) => value), save: jest.fn().mockResolvedValue(assistant), }; const messages = { exists: jest.fn().mockResolvedValue(false), findOne: jest.fn().mockResolvedValue(target), find: jest.fn().mockResolvedValue([]), save: jest.fn(async (value) => value), }; const conversations = { findOne: jest.fn().mockResolvedValue(conversation), update: jest.fn().mockResolvedValue(undefined), }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => value), find: jest.fn().mockResolvedValue([]), }; const modelStream = { stream: async function* () { yield { type: 'complete' as const, toolCalls: [] }; }, }; const removeOrphans = jest.fn().mockResolvedValue(undefined); const service = new AiChatService( conversations as never, messages as never, toolRuns as never, { transaction: jest.fn(async (callback) => callback(manager)) } as never, { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, { listAvailable: jest.fn().mockReturnValue([]) } as never, modelStream as never, { removeOrphans } as never, { createForm: jest.fn(), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), serialize: jest.fn((value) => value), } as never, { createReview: jest.fn(), expirePreviousReviews: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), findPendingByAssistantMessage: jest.fn(), serialize: jest.fn((value) => value), submit: jest.fn(), } as never, { createChart: jest.fn(), serialize: jest.fn((value) => value), } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; await service.editMessage( authenticatedUser as never, 3, 10, { content: '新问题', clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', }, new AbortController().signal, (event, data) => emitted.push({ event, data }), jest.fn(), ); expect(manager.update).toHaveBeenCalledWith( expect.anything(), { id: 10, conversationId: 3 }, expect.objectContaining({ content: '新问题' }), ); expect(manager.delete).toHaveBeenCalledWith(expect.anything(), [11, 12]); expect(conversations.update).toHaveBeenCalledWith( { id: 3, userId: 7 }, expect.objectContaining({ title: '新问题' }), ); expect(emitted.some(({ event }) => event === 'message.completed')).toBe(true); expect(removeOrphans).toHaveBeenCalledWith(7, []); }); it('start_import_wizard 阶段缺少 sheet 时失败,且不创建导入任务', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...toolRun, ...value })), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, metadata: null }), save: jest.fn(async (value) => value), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', }, ]), readStoredBuffer: jest.fn(), }; const importsService = { createRun: jest.fn() }; (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; const emitted: Array<{ event: string }> = []; const result = await ( service as unknown as { executeStartImportWizard( messageId: number, call: { id: string; name: string; arguments: string }, context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, emit: (event: string, data?: unknown) => void, ): Promise; } ).executeStartImportWizard( 42, { id: 'call-1', name: 'start_import_wizard', arguments: JSON.stringify({ attachmentId: 9, stages: [{ stepKey: 'students' }], }), }, { userId: 7, permissions: [], isSuperAdmin: false }, (event) => emitted.push({ event }), ); const parsed = JSON.parse(result) as { status: string; error: string }; expect(parsed.status).toBe('failed'); expect(parsed.error).toContain('缺少工作表 sheet'); expect(importsService.createRun).not.toHaveBeenCalled(); expect(emitted.some(({ event }) => event === 'tool.failed')).toBe(true); }); it('start_import_wizard 拒绝非法 headerRow', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...toolRun, ...value })), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, metadata: null }), save: jest.fn(async (value) => value), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', }, ]), readStoredBuffer: jest.fn(), }; const importsService = { createRun: jest.fn() }; (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; const result = await ( service as unknown as { executeStartImportWizard( messageId: number, call: { id: string; name: string; arguments: string }, context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, emit: (event: string, data?: unknown) => void, ): Promise; } ).executeStartImportWizard( 42, { id: 'call-1', name: 'start_import_wizard', arguments: JSON.stringify({ attachmentId: 9, stages: [{ stepKey: 'students', sheet: '学生', headerRow: 0 }], }), }, { userId: 7, permissions: [], isSuperAdmin: false }, jest.fn(), ); const parsed = JSON.parse(result) as { status: string; error: string }; expect(parsed.status).toBe('failed'); expect(parsed.error).toContain('headerRow'); expect(importsService.createRun).not.toHaveBeenCalled(); }); it('start_import_wizard 接收确认参数并写入导入任务', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...toolRun, ...value })), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), save: jest.fn(async (value) => value), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', size: 10, }, ]), readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), }; const importsService = { createRun: jest.fn().mockResolvedValue({ id: 'run-9', fileName: 'students.xlsx', sheets: [], steps: [ { stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }, ], }), }; (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; const result = await ( service as unknown as { executeStartImportWizard( messageId: number, call: { id: string; name: string; arguments: string }, context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, emit: (event: string, data?: unknown) => void, ): Promise; } ).executeStartImportWizard( 42, { id: 'call-1', name: 'start_import_wizard', arguments: JSON.stringify({ attachmentId: 9, stages: [{ stepKey: 'students', sheet: '学生', headerRow: 2 }], mapping: { students: { name: '姓名', studentNo: '学号' } }, organization: '主校区', updateExisting: false, duplicatePolicy: 'skip', skipUnmatched: true, }), }, { userId: 7, permissions: [], isSuperAdmin: false }, jest.fn(), ); const parsed = JSON.parse(result) as { status: string }; expect(parsed.status).toBe('success'); expect(parsed).toMatchObject({ permittedSteps: [] }); expect(importsService.createRun).toHaveBeenCalledWith( { id: 7, permissions: [], isSuperAdmin: false }, 'ai', expect.objectContaining({ originalName: 'students.xlsx' }), 3, [{ stepKey: 'students', sheet: '学生', headerRow: 2 }], { students: { name: '姓名', studentNo: '学号' } }, { organization: '主校区', updateExisting: false, duplicatePolicy: 'skip', skipUnmatched: true, }, ); }); it('preflight_import 生成预检报告并通过 ui.import_preflight 推送', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...toolRun, ...value })), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), save: jest.fn(async (value) => value), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', size: 10, }, ]), readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), }; const report = { verdict: 'ready', stages: [ { stepKey: 'students', label: '学生档案', sheetNames: ['学生'], total: 2, create: 2, update: 0, error: 0, skip: 0, mapping: { name: '姓名' }, missingRequired: [], }, ], blocks: [], questions: [], nextSteps: [], }; const importsService = { preflightFile: jest.fn().mockResolvedValue(report) }; (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; const emitted: Array<{ event: string; data: Record }> = []; const result = await ( service as unknown as { executePreflightImport( messageId: number, call: { id: string; name: string; arguments: string }, context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, emit: (event: string, data?: unknown) => void, ): Promise; } ).executePreflightImport( 42, { id: 'call-1', name: 'preflight_import', arguments: JSON.stringify({ attachmentId: 9 }), }, { userId: 7, permissions: [], isSuperAdmin: false }, (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), ); const parsed = JSON.parse(result) as { status: string; report: unknown }; expect(parsed.status).toBe('success'); expect(parsed.report).toEqual(report); expect(parsed).toMatchObject({ permittedSteps: [] }); expect(importsService.preflightFile).toHaveBeenCalledWith( expect.objectContaining({ originalName: 'students.xlsx' }), 1, ); expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true); expect(messages.save).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ a2uiImportPreflight: { ...report, attachmentId: 9, headerRow: 1, permittedSteps: [], resolved: false, runId: null, }, }), }), ); }); it('preflight_import 校验并透传 headerRow', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...toolRun, ...value })), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), save: jest.fn(async (value) => value), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', size: 10, }, ]), readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), }; const report = { verdict: 'ready', stages: [], blocks: [], questions: [], errorSamples: [], nextSteps: [], }; const importsService = { preflightFile: jest.fn().mockResolvedValue(report) }; (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; const result = await ( service as unknown as { executePreflightImport( messageId: number, call: { id: string; name: string; arguments: string }, context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, emit: (event: string, data?: unknown) => void, ): Promise; } ).executePreflightImport( 42, { id: 'call-1', name: 'preflight_import', arguments: JSON.stringify({ attachmentId: 9, headerRow: 5 }), }, { userId: 7, permissions: ['student:import'], isSuperAdmin: false }, jest.fn(), ); const parsed = JSON.parse(result) as { status: string; permittedSteps: string[] }; expect(parsed.status).toBe('success'); expect(parsed.permittedSteps).toEqual(['students']); expect(importsService.preflightFile).toHaveBeenCalledWith( expect.objectContaining({ originalName: 'students.xlsx' }), 5, ); }); it('preflight_import 结果超过 32KB 时返回精简版,SSE 仍推送完整报告', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...toolRun, ...value })), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), save: jest.fn(async (value) => value), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', size: 10, }, ]), readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), }; const report = { verdict: 'needs_input', stages: [ { stepKey: 'students', label: '学生档案', sheetNames: ['学生'], total: 2000, create: 0, update: 0, error: 2000, skip: 0, mapping: { name: '姓名' }, missingRequired: [], }, ], blocks: [], questions: [{ key: 'mapping_students', type: 'mapping', label: '确认列映射' }], errorSamples: Array.from({ length: 2000 }, (_, index) => ({ code: 'format_error', stepKey: 'students', sheet: '学生', rowNumber: index + 2, errors: ['手机号格式不正确:'.repeat(20)], })), nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '下一步' }], }; const importsService = { preflightFile: jest.fn().mockResolvedValue(report) }; (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; const emitted: Array<{ event: string; data: Record }> = []; const result = await ( service as unknown as { executePreflightImport( messageId: number, call: { id: string; name: string; arguments: string }, context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, emit: (event: string, data?: unknown) => void, ): Promise; } ).executePreflightImport( 42, { id: 'call-1', name: 'preflight_import', arguments: JSON.stringify({ attachmentId: 9 }), }, { userId: 7, permissions: ['student:import'], isSuperAdmin: false }, (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), ); const parsed = JSON.parse(result) as { status: string; truncated: boolean; report: { verdict: string; errorSamples: unknown[] }; }; expect(parsed.status).toBe('success'); expect(parsed.truncated).toBe(true); expect(parsed.report.verdict).toBe('needs_input'); expect(parsed.report.errorSamples).toHaveLength(10); const preflightEvent = emitted.find(({ event }) => event === 'ui.import_preflight'); expect(preflightEvent).toBeDefined(); expect( (preflightEvent?.data as { preflight?: { errorSamples?: unknown[] } }).preflight ?.errorSamples, ).toHaveLength(2000); }); it('start_import_wizard 拒绝非法的确认参数', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...toolRun, ...value })), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), save: jest.fn(async (value) => value), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', }, ]), readStoredBuffer: jest.fn(), }; const importsService = { createRun: jest.fn() }; (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; const result = await ( service as unknown as { executeStartImportWizard( messageId: number, call: { id: string; name: string; arguments: string }, context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, emit: (event: string, data?: unknown) => void, ): Promise; } ).executeStartImportWizard( 42, { id: 'call-1', name: 'start_import_wizard', arguments: JSON.stringify({ attachmentId: 9, stages: [{ stepKey: 'students', sheet: '学生' }], duplicatePolicy: 'bogus', }), }, { userId: 7, permissions: [], isSuperAdmin: false }, jest.fn(), ); const parsed = JSON.parse(result) as { status: string; error: string }; expect(parsed.status).toBe('failed'); expect(parsed.error).toContain('duplicatePolicy'); expect(importsService.createRun).not.toHaveBeenCalled(); }); it('resolveImportPreflight 生成导入任务并原位更新预检卡', async () => { const { service } = createService(); const conversations = { findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), }; const message = { id: 42, conversationId: 3, role: 'assistant', metadata: { a2uiImportPreflight: { verdict: 'needs_input', stages: [ { stepKey: 'students', label: '学生档案', sheetNames: ['学生'], headers: ['姓名', '学号', '手机号'], mapping: { name: '姓名' }, missingRequired: [], total: 1, create: 1, update: 0, error: 0, skip: 0, }, ], blocks: [], questions: [], nextSteps: [], errorSamples: [], attachmentId: 9, headerRow: 1, permittedSteps: ['students'], resolved: false, runId: null, }, }, }; const messages = { findOne: jest.fn().mockResolvedValue(message), save: jest.fn(async (value) => value), exists: jest.fn().mockResolvedValue(false), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', size: 10, }, ]), readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), }; const importsService = { createRun: jest.fn().mockResolvedValue({ id: 'run-9', fileName: 'students.xlsx', sheets: [], steps: [ { stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }, ], }), }; (service as unknown as { conversations: unknown }).conversations = conversations; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; const emitted: Array<{ event: string; data: Record }> = []; await service.resolveImportPreflight( authenticatedUser, 42, { clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e', mapping: { students: { name: '姓名', studentNo: '学号' } }, settings: { updateExisting: false }, }, new AbortController().signal, (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), jest.fn(), ); expect(importsService.createRun).toHaveBeenCalledWith( { id: 7, permissions: ['ai:chat:use'], isSuperAdmin: false }, 'ai', expect.objectContaining({ originalName: 'students.xlsx' }), 3, [{ stepKey: 'students', sheets: ['学生'], headerRow: 1 }], { students: { name: '姓名', studentNo: '学号' } }, { updateExisting: false }, ); expect(messages.save).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ a2uiImportPreflight: expect.objectContaining({ resolved: true, runId: 'run-9' }), a2uiImportWizard: expect.objectContaining({ runId: 'run-9' }), }), }), ); expect(emitted.map(({ event }) => event)).toEqual( expect.arrayContaining(['ui.import_preflight', 'ui.import_wizard']), ); const preflightEvent = emitted.find(({ event }) => event === 'ui.import_preflight'); expect( (preflightEvent?.data as { preflight?: { resolved?: boolean; runId?: string | null } }) .preflight, ).toMatchObject({ resolved: true, runId: 'run-9' }); }); it('resolveImportPreflight 多工作表阶段携带全部 sheetNames 生成导入任务', async () => { const { service } = createService(); const conversations = { findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), }; const message = { id: 42, conversationId: 3, role: 'assistant', metadata: { a2uiImportPreflight: { verdict: 'needs_input', stages: [ { stepKey: 'checkins', label: '入住管理', sheetNames: ['四人间女', '四人间男'], headers: ['姓名', '学号', '手机号', '宿舍号', '入住日期'], mapping: { name: '姓名', roomNumber: '宿舍号' }, missingRequired: [], total: 2, create: 2, update: 0, error: 0, skip: 0, }, ], blocks: [], questions: [], nextSteps: [], errorSamples: [], attachmentId: 9, headerRow: 1, permittedSteps: ['checkins'], resolved: false, runId: null, }, }, }; const messages = { findOne: jest.fn().mockResolvedValue(message), save: jest.fn(async (value) => value), exists: jest.fn().mockResolvedValue(false), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'dorm.xlsx', size: 10, }, ]), readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), }; const importsService = { createRun: jest.fn().mockResolvedValue({ id: 'run-9', fileName: 'dorm.xlsx', sheets: [], steps: [ { stepKey: 'checkins', label: '入住管理', sheets: ['四人间女', '四人间男'], status: 'pending', }, ], }), }; (service as unknown as { conversations: unknown }).conversations = conversations; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; await service.resolveImportPreflight( authenticatedUser, 42, { clientRequestId: 'multi-sheet', mapping: {}, settings: {} }, new AbortController().signal, jest.fn(), jest.fn(), ); expect(importsService.createRun).toHaveBeenCalledWith( { id: 7, permissions: ['ai:chat:use'], isSuperAdmin: false }, 'ai', expect.objectContaining({ originalName: 'dorm.xlsx' }), 3, [{ stepKey: 'checkins', sheets: ['四人间女', '四人间男'], headerRow: 1 }], {}, {}, ); }); it('resolveImportPreflight 已生成向导时幂等重放,不重复建任务', async () => { const { service } = createService(); const conversations = { findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), }; const preflight = { verdict: 'ready', stages: [], blocks: [], questions: [], nextSteps: [], errorSamples: [], attachmentId: 9, headerRow: 1, permittedSteps: [], resolved: true, runId: 'run-1', }; const messages = { findOne: jest .fn() .mockResolvedValue({ id: 42, conversationId: 3, role: 'assistant', metadata: { a2uiImportPreflight: preflight, a2uiImportWizard: { runId: 'run-1', fileName: 'students.xlsx', sheets: [], steps: [] }, }, }), exists: jest.fn().mockResolvedValue(false), }; const importsService = { createRun: jest.fn() }; (service as unknown as { conversations: unknown }).conversations = conversations; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { importsService: unknown }).importsService = importsService; const emitted: Array<{ event: string }> = []; await service.resolveImportPreflight( authenticatedUser, 42, { clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e' }, new AbortController().signal, (event) => emitted.push({ event }), jest.fn(), ); expect(importsService.createRun).not.toHaveBeenCalled(); expect(emitted.map(({ event }) => event)).toEqual( expect.arrayContaining(['ui.import_preflight', 'ui.import_wizard']), ); }); it('resolveImportPreflight 无预检 metadata 时拒绝', async () => { const { service } = createService(); const conversations = { findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, role: 'assistant', metadata: null, }), exists: jest.fn().mockResolvedValue(false), }; const importsService = { createRun: jest.fn() }; (service as unknown as { conversations: unknown }).conversations = conversations; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { importsService: unknown }).importsService = importsService; await expect( service.resolveImportPreflight( authenticatedUser, 42, { clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e' }, new AbortController().signal, jest.fn(), jest.fn(), ), ).rejects.toBeInstanceOf(BadRequestException); expect(importsService.createRun).not.toHaveBeenCalled(); }); it('resolveImportPreflight 拒绝映射到表头之外的列', async () => { const { service } = createService(); const conversations = { findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, role: 'assistant', metadata: { a2uiImportPreflight: { verdict: 'blocked', stages: [ { stepKey: 'students', label: '学生档案', sheetNames: ['学生'], headers: ['姓名', '学号'], mapping: { name: '姓名' }, missingRequired: [], total: 1, create: 0, update: 0, error: 0, skip: 0, }, ], blocks: [], questions: [], nextSteps: [], errorSamples: [], attachmentId: 9, headerRow: 1, permittedSteps: ['students'], resolved: false, runId: null, }, }, }), exists: jest.fn().mockResolvedValue(false), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', size: 10, }, ]), readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), }; const importsService = { createRun: jest.fn() }; (service as unknown as { conversations: unknown }).conversations = conversations; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; await expect( service.resolveImportPreflight( authenticatedUser, 42, { clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e', mapping: { students: { name: '不存在的列' } }, }, new AbortController().signal, jest.fn(), jest.fn(), ), ).rejects.toThrow('不在工作表表头中'); expect(importsService.createRun).not.toHaveBeenCalled(); }); it('resolveImportPreflight 拒绝非法的策略参数', async () => { const { service } = createService(); const conversations = { findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, role: 'assistant', metadata: { a2uiImportPreflight: { verdict: 'ready', stages: [], blocks: [], questions: [], nextSteps: [], errorSamples: [], attachmentId: 9, headerRow: 1, permittedSteps: [], resolved: false, runId: null, }, }, }), exists: jest.fn().mockResolvedValue(false), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', size: 10, }, ]), readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), }; const importsService = { createRun: jest.fn() }; (service as unknown as { conversations: unknown }).conversations = conversations; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; await expect( service.resolveImportPreflight( authenticatedUser, 42, { clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e', settings: { duplicatePolicy: 'bogus' }, }, new AbortController().signal, jest.fn(), jest.fn(), ), ).rejects.toThrow('duplicatePolicy'); expect(importsService.createRun).not.toHaveBeenCalled(); }); it('resolvePreflightConversationId 返回消息所属会话', async () => { const { service } = createService(); const conversations = { findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, role: 'assistant', conversation: { id: 3, userId: 7 }, }), }; (service as unknown as { conversations: unknown }).conversations = conversations; (service as unknown as { messages: unknown }).messages = messages; await expect(service.resolvePreflightConversationId(7, 42)).resolves.toBe(3); expect(messages.findOne).toHaveBeenCalledWith({ where: { id: 42 }, relations: { conversation: true }, }); }); it('start_import_wizard 在已有预检卡时同步标记 resolved', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; const toolRuns = { create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...toolRun, ...value })), }; const messages = { findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: { a2uiImportPreflight: { verdict: 'ready', stages: [], blocks: [], questions: [], nextSteps: [], errorSamples: [], attachmentId: 9, headerRow: 1, permittedSteps: ['students'], resolved: false, runId: null, }, }, }), save: jest.fn(async (value) => value), }; const attachmentService = { requireReadyOwned: jest.fn().mockResolvedValue([ { id: 9, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', originalName: 'students.xlsx', size: 10, }, ]), readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), }; const importsService = { createRun: jest.fn().mockResolvedValue({ id: 'run-9', fileName: 'students.xlsx', sheets: [], steps: [ { stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }, ], }), }; (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; (service as unknown as { messages: unknown }).messages = messages; (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; (service as unknown as { importsService: unknown }).importsService = importsService; const emitted: Array<{ event: string; data: Record }> = []; await ( service as unknown as { executeStartImportWizard( messageId: number, call: { id: string; name: string; arguments: string }, context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, emit: (event: string, data?: unknown) => void, ): Promise; } ).executeStartImportWizard( 42, { id: 'call-1', name: 'start_import_wizard', arguments: JSON.stringify({ attachmentId: 9, stages: [{ stepKey: 'students', sheet: '学生' }], }), }, { userId: 7, permissions: ['student:import'], isSuperAdmin: false }, (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), ); expect(messages.save).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ a2uiImportPreflight: expect.objectContaining({ resolved: true, runId: 'run-9' }), a2uiImportWizard: expect.objectContaining({ runId: 'run-9' }), }), }), ); expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true); }); });