Files
gongxue-base/apps/server/src/ai-chat/ai-chat.service.spec.ts
wangziqi ae88372ef8 fix(imports): 修复 AI 导入向导多工作表与表头误判
- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表
- ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复
- headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配
- suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿
- 预检与预览按工作表逐表解析列映射,兼容异构表头
- 修复预检卡生成向导成功后按钮未复位 loading 的问题
- 补充 mapping/预检/run/ai-chat 多工作表测试
2026-08-06 14:46:33 +08:00

2337 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> = {},
messageOverrides: Record<string, unknown> = {},
) {
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<number> }).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<number> }).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<boolean>((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<void> }
).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<string | unknown[]>;
}
).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<string | unknown[]>;
}
).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<string, unknown> }> = [];
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<string, unknown>) => void,
): Promise<string>;
}
).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<string, unknown> }> = [];
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<string, unknown> }> = [];
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<string, unknown> }> = [];
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<number> }).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<string, unknown> }> = [];
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<string>;
}
).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<string>;
}
).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<string>;
}
).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<string, unknown> }> = [];
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<string>;
}
).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<string, unknown> }),
);
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<string>;
}
).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<string, unknown> }> = [];
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<string>;
}
).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<string, unknown> }),
);
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<string>;
}
).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<string, unknown> }> = [];
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<string, unknown> }),
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<string, unknown> }> = [];
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<string>;
}
).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<string, unknown> }),
);
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);
});
});