Files
gongxue-base/apps/server/src/imports/imports.service.spec.ts
wangziqi a644a8de42 refactor(server): 清理全量 any 类型安全警告 (692 → 0)
- 全模块类型化:controller 的 req: any → AuthenticatedRequest/RequestUser,
  聚合查询 getRawMany 泛型标注、导入行/响应体定义具体 interface、
  catch (e: any) → unknown + 收窄、no-base-to-string 用 String() 显式转换
- 第三方无类型库边界(pdfkit/exceljs)文件级或单行 disable 并注明理由
- 顺带修复:get-business-context.tool 两个 require-await error、
  bills.controller 参数顺序隐患、main.ts compression 调用
- 运行时逻辑零改动;测试 142 套件 / 1065 用例全部通过
2026-08-08 09:28:23 +08:00

943 lines
29 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, ForbiddenException } from '@nestjs/common';
import * as ExcelJS from 'exceljs';
import { Organization } from '../entities/organization.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { ImportRun } from './entities/import-run.entity';
import { ImportStep } from './entities/import-step.entity';
import { ImportRow } from './entities/import-row.entity';
import { ImportsService } from './imports.service';
import type { ParsedImportFile } from './imports.types';
function makeRowsRepo() {
let nextId = 1;
return {
create: jest.fn((value: unknown) => value),
save: jest.fn(async (rows: unknown[]) => {
const list = Array.isArray(rows) ? rows : [rows];
for (const row of list) {
const record = row as { id?: number };
if (record.id === undefined) record.id = nextId++;
}
return list;
}),
delete: jest.fn().mockResolvedValue({ affected: 0 }),
find: jest.fn().mockResolvedValue([]),
};
}
function makeStepsRepo(step: ImportStep) {
return {
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: unknown) => value),
findOne: jest.fn().mockResolvedValue(step),
find: jest.fn().mockResolvedValue([]),
};
}
function makeRunsRepo(run: ImportRun) {
return {
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: unknown) => value),
findOne: jest.fn().mockResolvedValue(run),
};
}
function studentSheet() {
return {
name: '学生',
headers: ['姓名', '学号', '手机号'],
rows: [['张三', '2024001', '13800138000']],
};
}
async function xlsxBuffer(sheet: {
name: string;
headers: string[];
rows: unknown[][];
}): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet(sheet.name);
ws.addRow(sheet.headers);
for (const row of sheet.rows) ws.addRow(row);
return (await workbook.xlsx.writeBuffer()) as Buffer;
}
function fileOf(name: string, buffer: Buffer): ParsedImportFile {
return {
originalName: name,
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: buffer.length,
buffer,
};
}
const principal = {
id: 7,
permissions: ['student:import', 'room:create', 'occupancy:checkin'],
isSuperAdmin: false,
};
describe('ImportsService', () => {
it('拒绝 .xls 文件', async () => {
const service = new ImportsService(
makeRunsRepo({} as ImportRun) as never,
makeStepsRepo({} as ImportStep) as never,
makeRowsRepo() as never,
{} as never,
);
await expect(
service.createRun(principal, 'manual', fileOf('a.xls', Buffer.from('not excel'))),
).rejects.toThrow('暂不支持 .xls');
});
it('上传学生表时自动分配 students 阶段并返回运行详情', async () => {
const buffer = await xlsxBuffer(studentSheet());
const run = {
id: 'run-1',
userId: 7,
conversationId: null,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: JSON.stringify([studentSheet()]),
status: 'ready',
currentStepKey: 'students',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const steps = [
{
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: '{"name":"姓名","studentNo":"学号","phone":"手机号"}',
status: 'pending',
},
{
id: 2,
runId: 'run-1',
stepKey: 'rooms',
sheetsJson: '[]',
mappingJson: null,
status: 'skipped',
},
{
id: 3,
runId: 'run-1',
stepKey: 'checkins',
sheetsJson: '[]',
mappingJson: null,
status: 'skipped',
},
{
id: 4,
runId: 'run-1',
stepKey: 'transfers',
sheetsJson: '[]',
mappingJson: null,
status: 'skipped',
},
] as unknown as ImportStep[];
const runsRepo = makeRunsRepo(run);
const stepsRepo = {
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: unknown) => value),
findOne: jest.fn().mockResolvedValue(null),
find: jest.fn().mockResolvedValue(steps),
};
const service = new ImportsService(
runsRepo as never,
stepsRepo as never,
makeRowsRepo() as never,
{} as never,
);
const detail = await service.createRun(principal, 'manual', fileOf('students.xlsx', buffer));
expect(detail.currentStepKey).toBe('students');
expect(detail.steps.find((step) => step.stepKey === 'students')?.sheets).toEqual(['学生']);
expect(detail.steps.find((step) => step.stepKey === 'students')?.mapping).toEqual({
name: '姓名',
studentNo: '学号',
phone: '手机号',
});
});
it('显式多工作表阶段时完整分配所有工作表', async () => {
const workbook = new ExcelJS.Workbook();
const girls = workbook.addWorksheet('四人间女');
girls.addRow(['姓名', '学号', '宿舍号', '入住日期']);
girls.addRow(['张三', '2024001', 'A101', '2026-09-01']);
const boys = workbook.addWorksheet('四人间男');
boys.addRow(['姓名', '学号', '宿舍号', '入住日期']);
boys.addRow(['李四', '2024002', 'A101', '2026-09-02']);
const buffer = (await workbook.xlsx.writeBuffer()) as Buffer;
const run = {
id: 'run-2',
userId: 7,
conversationId: null,
source: 'manual',
fileName: 'dorm.xlsx',
sheetsJson: '[]',
status: 'ready',
currentStepKey: 'checkins',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const runsRepo = makeRunsRepo(run);
const stepsRepo = {
create: jest.fn((value: unknown) => value),
save: jest.fn(async (value: unknown) => value),
findOne: jest.fn().mockResolvedValue(null),
find: jest.fn().mockResolvedValue([]),
};
const service = new ImportsService(
runsRepo as never,
stepsRepo as never,
makeRowsRepo() as never,
{} as never,
);
await service.createRun(
principal,
'manual',
fileOf('dorm.xlsx', buffer),
null,
[{ stepKey: 'checkins', sheets: ['四人间女', '四人间男'], headerRow: 1 }],
);
const savedRun = runsRepo.create.mock.calls[0][0] as { sheetsJson: string };
const savedSheets = JSON.parse(savedRun.sheetsJson) as Array<{ name: string }>;
expect(savedSheets.map((sheet) => sheet.name)).toEqual(['四人间女', '四人间男']);
const checkinStep = stepsRepo.create.mock.calls.find(
(call) => (call[0] as { stepKey: string }).stepKey === 'checkins',
)?.[0] as { sheetsJson: string };
expect(JSON.parse(checkinStep.sheetsJson)).toEqual(['四人间女', '四人间男']);
});
it('预览学生阶段:已有学号判为更新,并保留目标记录 ID', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const run = {
id: 'run-1',
userId: 7,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: JSON.stringify([studentSheet()]),
status: 'ready',
currentStepKey: 'students',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-1', 'students', {
sheets: ['学生'],
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
});
expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 });
expect(result.rows[0].action).toBe('update');
expect(result.rows[0].status).toBe('valid');
expect(result.rows[0].id).toBeDefined();
});
it('预览入住阶段:宿舍不存在时按依赖错误提示', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const run = {
id: 'run-2',
userId: 7,
source: 'manual',
fileName: 'checkins.xlsx',
sheetsJson: JSON.stringify([
{
name: '入住',
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
rows: [['张三', '13800138000', 'A101', '2026-09-01']],
},
]),
status: 'ready',
currentStepKey: 'checkins',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 3,
runId: 'run-2',
stepKey: 'checkins',
sheetsJson: '["入住"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-2', 'checkins', {
sheets: ['入住'],
mapping: {
name: '姓名',
phone: '手机号',
roomNumber: '宿舍号',
checkInDate: '入住日期',
},
});
expect(result.summary).toMatchObject({ total: 1, valid: 0, error: 1 });
expect(result.rows[0].status).toBe('error');
expect(result.rows[0].errors.join('')).toContain('未找到宿舍');
});
it('提交阶段需要对应权限;已完成的任务幂等返回回执', async () => {
const run = {
id: 'run-1',
userId: 7,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: '[]',
status: 'ready',
currentStepKey: 'transfers',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 4,
runId: 'run-1',
stepKey: 'transfers',
sheetsJson: '["换宿"]',
mappingJson: '{}',
status: 'ready',
} as ImportStep;
const rowsRepo = makeRowsRepo();
rowsRepo.find.mockResolvedValue([]);
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
{} as never,
);
await expect(
service.commitStep(
{ id: 7, permissions: ['student:import'], isSuperAdmin: false },
'run-1',
'transfers',
[],
),
).rejects.toBeInstanceOf(ForbiddenException);
const committedRun = { ...run, status: 'committed', currentStepKey: null } as ImportRun;
const committedStep = {
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: '{}',
status: 'committed',
summaryJson: JSON.stringify({ total: 1, valid: 1, error: 0, create: 1, update: 0, skip: 0 }),
} as ImportStep;
const committedService = new ImportsService(
makeRunsRepo(committedRun) as never,
makeStepsRepo(committedStep) as never,
makeRowsRepo() as never,
{} as never,
);
const receipt = await committedService.commitStep(principal, 'run-1', 'students', []);
expect(receipt.status).toBe('already_committed');
expect(receipt.created).toBe(1);
});
it('提交阶段拒绝与预览分类矛盾的决策', async () => {
const run = {
id: 'run-1',
userId: 7,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: '[]',
status: 'ready',
currentStepKey: 'students',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: '{}',
status: 'ready',
} as ImportStep;
const row = {
id: 11,
runId: 'run-1',
stepId: 1,
sheetName: '学生',
rowNumber: 3,
rawJson: '{}',
normalizedJson: JSON.stringify({ name: '张三', studentNo: '2024001', phone: '13800138000' }),
matchKey: '2024001',
action: 'update',
status: 'valid',
errorsJson: null,
targetId: 88,
} as ImportRow;
const rowsRepo = makeRowsRepo();
rowsRepo.find.mockResolvedValue([row]);
const dataSource = {
transaction: jest.fn(),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
await expect(
service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
).rejects.toThrow('预览判定为「更新」');
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('预览学生阶段:学号未命中时回退到手机号匹配', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const run = {
id: 'run-1',
userId: 7,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: JSON.stringify([
{
name: '学生',
headers: ['姓名', '学号', '手机号'],
rows: [['张三', '2024999', '13800138000']],
},
]),
status: 'ready',
currentStepKey: 'students',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-1', 'students', {
sheets: ['学生'],
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
});
expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 });
expect(result.rows[0].action).toBe('update');
expect(result.rows[0].status).toBe('valid');
expect(result.rows[0].id).toBeDefined();
});
it('预览入住阶段:同一文件内重复入住标记为错误', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const room = { id: 5, roomNumber: 'A101' } as Room;
const run = {
id: 'run-2',
userId: 7,
source: 'manual',
fileName: 'checkins.xlsx',
sheetsJson: JSON.stringify([
{
name: '入住',
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
rows: [
['张三', '13800138000', 'A101', '2026-09-01'],
['张三', '13800138000', 'A101', '2026-09-02'],
],
},
]),
status: 'ready',
currentStepKey: 'checkins',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 3,
runId: 'run-2',
stepKey: 'checkins',
sheetsJson: '["入住"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([room]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-2', 'checkins', {
sheets: ['入住'],
mapping: {
name: '姓名',
phone: '手机号',
roomNumber: '宿舍号',
checkInDate: '入住日期',
},
});
expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 });
expect(result.rows[0].status).toBe('valid');
expect(result.rows[1].status).toBe('error');
expect(result.rows[1].errors.join('')).toContain('请勿重复导入');
});
it('预览换宿阶段:同一文件内重复换宿标记为错误', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const oldRoom = { id: 5, roomNumber: 'A101' } as Room;
const newRoom = { id: 6, roomNumber: 'B202' } as Room;
const run = {
id: 'run-3',
userId: 7,
source: 'manual',
fileName: 'transfers.xlsx',
sheetsJson: JSON.stringify([
{
name: '换宿',
headers: ['姓名', '手机号', '原宿舍', '新宿舍', '换宿日期'],
rows: [
['张三', '13800138000', 'A101', 'B202', '2026-09-10'],
['张三', '13800138000', 'A101', 'B202', '2026-09-11'],
],
},
]),
status: 'ready',
currentStepKey: 'transfers',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 4,
runId: 'run-3',
stepKey: 'transfers',
sheetsJson: '["换宿"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([oldRoom, newRoom]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) {
return {
find: jest
.fn()
.mockResolvedValue([{ id: 77, studentId: 88, roomId: 5, status: 'active' }]),
};
}
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-3', 'transfers', {
sheets: ['换宿'],
mapping: {
name: '姓名',
phone: '手机号',
oldRoom: '原宿舍',
newRoom: '新宿舍',
transferDate: '换宿日期',
},
});
expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 });
expect(result.rows[0].status).toBe('valid');
expect(result.rows[1].status).toBe('error');
expect(result.rows[1].errors.join('')).toContain('请勿重复换宿');
});
it('预览学生阶段updateExisting=false 时已匹配行改为跳过', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const run = {
id: 'run-1',
userId: 7,
source: 'ai',
fileName: 'students.xlsx',
sheetsJson: JSON.stringify([studentSheet()]),
settingsJson: JSON.stringify({ updateExisting: false }),
status: 'ready',
currentStepKey: 'students',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 1,
runId: 'run-1',
stepKey: 'students',
sheetsJson: '["学生"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-1', 'students', {
sheets: ['学生'],
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
});
expect(result.summary).toMatchObject({
total: 1,
valid: 1,
create: 0,
update: 0,
skip: 1,
error: 0,
});
expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' });
expect(result.rows[0].errors.join('')).toContain('按策略跳过更新');
});
it('预览入住阶段duplicatePolicy=skip 时文件内重复行跳过并保留提示', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const room = { id: 5, roomNumber: 'A101' } as Room;
const run = {
id: 'run-2',
userId: 7,
source: 'ai',
fileName: 'checkins.xlsx',
sheetsJson: JSON.stringify([
{
name: '入住',
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
rows: [
['张三', '13800138000', 'A101', '2026-09-01'],
['张三', '13800138000', 'A101', '2026-09-02'],
],
},
]),
settingsJson: JSON.stringify({ duplicatePolicy: 'skip' }),
status: 'ready',
currentStepKey: 'checkins',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 3,
runId: 'run-2',
stepKey: 'checkins',
sheetsJson: '["入住"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([room]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-2', 'checkins', {
sheets: ['入住'],
mapping: {
name: '姓名',
phone: '手机号',
roomNumber: '宿舍号',
checkInDate: '入住日期',
},
});
expect(result.summary).toMatchObject({
total: 2,
valid: 2,
error: 0,
create: 1,
skip: 1,
});
expect(result.rows[1]).toMatchObject({ action: 'skip', status: 'valid' });
expect(result.rows[1].errors.join('')).toContain('已按策略跳过');
});
it('预览入住阶段skipUnmatched=true 时找不到宿舍的行跳过并保留提示', async () => {
const existing = {
id: 88,
name: '张三',
studentNo: '2024001',
phone: '13800138000',
} as Student;
const run = {
id: 'run-2',
userId: 7,
source: 'ai',
fileName: 'checkins.xlsx',
sheetsJson: JSON.stringify([
{
name: '入住',
headers: ['姓名', '手机号', '宿舍号', '入住日期'],
rows: [['张三', '13800138000', 'A101', '2026-09-01']],
},
]),
settingsJson: JSON.stringify({ skipUnmatched: true }),
status: 'ready',
currentStepKey: 'checkins',
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const step = {
id: 3,
runId: 'run-2',
stepKey: 'checkins',
sheetsJson: '["入住"]',
mappingJson: null,
status: 'pending',
} as ImportStep;
const rowsRepo = makeRowsRepo();
const dataSource = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
return { find: jest.fn().mockResolvedValue([]) };
}),
};
const service = new ImportsService(
makeRunsRepo(run) as never,
makeStepsRepo(step) as never,
rowsRepo as never,
dataSource as never,
);
const result = await service.previewStep(principal, 'run-2', 'checkins', {
sheets: ['入住'],
mapping: {
name: '姓名',
phone: '手机号',
roomNumber: '宿舍号',
checkInDate: '入住日期',
},
});
expect(result.summary).toMatchObject({
total: 1,
valid: 1,
error: 0,
create: 0,
skip: 1,
});
expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' });
expect(result.rows[0].errors.join('')).toContain('按策略跳过');
});
it('createRun 按 stages 的 headerRow 生成对应工作表视图并写入 sheetsJson', async () => {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('名单');
worksheet.addRow(['标题行', null]);
worksheet.addRow(['姓名', '学号']);
worksheet.addRow(['', '']);
worksheet.addRow(['张三', '2024001']);
const buffer = (await workbook.xlsx.writeBuffer()) as Buffer;
const run = {
id: 'run-h',
userId: 7,
conversationId: null,
source: 'manual',
fileName: 'students.xlsx',
sheetsJson: '[]',
status: 'ready',
currentStepKey: null,
error: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ImportRun;
const runsRepo = makeRunsRepo(run);
const stepsRepo = makeStepsRepo({} as ImportStep);
const service = new ImportsService(
runsRepo as never,
stepsRepo as never,
makeRowsRepo() as never,
{} as never,
);
await service.createRun(
principal,
'manual',
fileOf('students.xlsx', buffer),
null,
[{ stepKey: 'students', sheet: '名单', headerRow: 2 }],
);
const created = runsRepo.create.mock.calls[0][0] as { sheetsJson: string };
const sheets = JSON.parse(created.sheetsJson) as Array<{
headers: string[];
rows: unknown[][];
headerRow: number;
rowNumbers: number[];
}>;
expect(sheets).toHaveLength(1);
expect(sheets[0].headers).toEqual(['姓名', '学号']);
expect(sheets[0].rows).toEqual([['张三', '2024001']]);
expect(sheets[0].headerRow).toBe(2);
expect(sheets[0].rowNumbers).toEqual([4]);
});
it('createRun 拒绝映射到工作表表头之外的列名', async () => {
const buffer = await xlsxBuffer(studentSheet());
const service = new ImportsService(
makeRunsRepo({} as ImportRun) as never,
makeStepsRepo({} as ImportStep) as never,
makeRowsRepo() as never,
{} as never,
);
await expect(
service.createRun(
principal,
'manual',
fileOf('students.xlsx', buffer),
null,
[{ stepKey: 'students', sheet: '学生' }],
{ students: { name: '姓名', studentNo: '不存在的列' } },
),
).rejects.toThrow('不在工作表表头中');
});
});