- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表 - ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复 - headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配 - suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿 - 预检与预览按工作表逐表解析列映射,兼容异构表头 - 修复预检卡生成向导成功后按钮未复位 loading 的问题 - 补充 mapping/预检/run/ai-chat 多工作表测试
2337 lines
79 KiB
TypeScript
2337 lines
79 KiB
TypeScript
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);
|
||
});
|
||
});
|