- create() 班级/学生/教师写入整体包事务,避免教师失败留下孤儿班级 - 花名册提交:行内带标识的同名新生不再复用本批姓名兜底(避免被误判冲突丢弃,与预览一致) - 迁移:超长科目截断前备份原值,且备份表确保存在(全新部署不因缺表启动失败) - workbook rels 解析真实工作表路径,防自定义文件名绕过 sheet/行数预扫描 - keepPlus 完整正则对齐注释意图(+ 86 前缀保留);formatDate 用 UTC getter - main.ts 大 JSON 路由前缀与限制提取为常量并注明耦合点
358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
||
import { ClassesService, normalizeTeacherSubjects } from './classes.service';
|
||
import { Class, ClassStudent, ClassTeacher, TeacherRoleType } from '../entities';
|
||
|
||
/** 模拟 TypeORM EntityManager:按实体类型分发 create/save/find/update,
|
||
* 班级/学生/教师写入走各自 repo mock,供 create() 事务化后的测试使用。 */
|
||
function entityAwareManager(
|
||
classRepo: Record<string, jest.Mock>,
|
||
classTeacherRepo: Record<string, jest.Mock>,
|
||
classStudentRepo: Record<string, jest.Mock>,
|
||
) {
|
||
return {
|
||
create: (entity: unknown, value: unknown) =>
|
||
entity === ClassTeacher ? classTeacherRepo.create(value) : classRepo.create(value),
|
||
save: async (entity: unknown, value?: unknown) => {
|
||
if (entity === ClassTeacher) return classTeacherRepo.save(value);
|
||
if (entity === ClassStudent) return value;
|
||
return classRepo.save(entity);
|
||
},
|
||
find: (entity: unknown, opts: unknown) =>
|
||
entity === ClassTeacher ? classTeacherRepo.find(opts) : classStudentRepo.find(opts),
|
||
update: (_entity: unknown, id: unknown, patch: unknown) => classRepo.update(id, patch),
|
||
};
|
||
}
|
||
|
||
function createService(
|
||
classRepo: Record<string, jest.Mock>,
|
||
classTeacherRepo: Record<string, jest.Mock>,
|
||
classStudentRepo: Record<string, jest.Mock> = { find: jest.fn().mockResolvedValue([]) },
|
||
dataSource: Record<string, unknown> = {},
|
||
) {
|
||
const ds = {
|
||
// 默认把事务回调的 manager 代理到 repo mock,保持既有断言语义
|
||
transaction: async (fn: (manager: Record<string, jest.Mock>) => unknown) =>
|
||
fn({
|
||
save: classTeacherRepo.save,
|
||
create: classTeacherRepo.create,
|
||
find: classTeacherRepo.find ?? jest.fn().mockResolvedValue([]),
|
||
update: classRepo.update ?? jest.fn().mockResolvedValue(undefined),
|
||
}),
|
||
...dataSource,
|
||
};
|
||
return new ClassesService(
|
||
classRepo as never,
|
||
classStudentRepo as never,
|
||
classTeacherRepo as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
ds as never,
|
||
{} as never,
|
||
{} as never,
|
||
);
|
||
}
|
||
|
||
describe('normalizeTeacherSubjects', () => {
|
||
it('trims, deduplicates, and drops empty/oversized subjects', () => {
|
||
expect(
|
||
normalizeTeacherSubjects([' 数学 ', '数学', ' ', '英语', 'x'.repeat(31), '语文']),
|
||
).toEqual(['数学', '英语', '语文']);
|
||
});
|
||
|
||
it('returns an empty array for undefined/null values and caps at 20', () => {
|
||
expect(normalizeTeacherSubjects(undefined)).toEqual([]);
|
||
const many = Array.from({ length: 25 }, (_, i) => `科目${i}`);
|
||
expect(normalizeTeacherSubjects(many)).toHaveLength(20);
|
||
});
|
||
});
|
||
|
||
describe('ClassesService — teacher multi-subject (one row per subject)', () => {
|
||
it('creates one class_teacher row per subject for subject_teacher', async () => {
|
||
const created: ClassTeacher[] = [];
|
||
const classTeacherRepo = {
|
||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: created.length + 1, ...value })),
|
||
save: jest.fn().mockImplementation(async (entries: ClassTeacher | ClassTeacher[]) => {
|
||
created.push(...(Array.isArray(entries) ? entries : [entries]));
|
||
return entries;
|
||
}),
|
||
find: jest.fn().mockResolvedValue([]),
|
||
};
|
||
const classRepo = {
|
||
findOne: jest.fn().mockResolvedValue({ id: 1 }),
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
const service = createService(classRepo, classTeacherRepo);
|
||
|
||
const result = await service.addTeacher(1, {
|
||
userId: 10,
|
||
roleType: TeacherRoleType.SUBJECT_TEACHER,
|
||
subjects: ['数学', '英语', ' 数学 '],
|
||
});
|
||
|
||
expect(result).toHaveLength(2);
|
||
expect(classTeacherRepo.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ classId: 1, userId: 10, roleType: 'subject_teacher', subject: '数学' }),
|
||
);
|
||
expect(classTeacherRepo.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ classId: 1, userId: 10, roleType: 'subject_teacher', subject: '英语' }),
|
||
);
|
||
expect(classTeacherRepo.save).toHaveBeenCalledTimes(2);
|
||
expect(classRepo.update).toHaveBeenCalled();
|
||
});
|
||
|
||
it('rejects subject_teacher without any subject', async () => {
|
||
const classTeacherRepo = {
|
||
create: jest.fn(),
|
||
save: jest.fn(),
|
||
find: jest.fn(),
|
||
findOne: jest.fn(),
|
||
};
|
||
const service = createService(
|
||
{ findOne: jest.fn().mockResolvedValue({ id: 1 }), update: jest.fn() },
|
||
classTeacherRepo,
|
||
);
|
||
|
||
await expect(
|
||
service.addTeacher(1, { userId: 10, roleType: TeacherRoleType.SUBJECT_TEACHER }),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
await expect(
|
||
service.addTeacher(1, {
|
||
userId: 10,
|
||
roleType: TeacherRoleType.SUBJECT_TEACHER,
|
||
subjects: [' '],
|
||
}),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
expect(classTeacherRepo.save).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('skips subjects that already exist and appends new ones', async () => {
|
||
const created: ClassTeacher[] = [];
|
||
const classTeacherRepo = {
|
||
find: jest
|
||
.fn()
|
||
.mockResolvedValueOnce([
|
||
{ id: 1, classId: 1, userId: 10, roleType: 'subject_teacher', subject: '数学' },
|
||
])
|
||
.mockResolvedValue([]),
|
||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 2, ...value })),
|
||
save: jest.fn().mockImplementation(async (entries: ClassTeacher | ClassTeacher[]) => {
|
||
created.push(...(Array.isArray(entries) ? entries : [entries]));
|
||
return entries;
|
||
}),
|
||
};
|
||
const service = createService(
|
||
{ findOne: jest.fn().mockResolvedValue({ id: 1 }), update: jest.fn() },
|
||
classTeacherRepo,
|
||
);
|
||
|
||
const result = await service.addTeacher(1, {
|
||
userId: 10,
|
||
roleType: TeacherRoleType.SUBJECT_TEACHER,
|
||
subjects: ['数学', '物理'],
|
||
});
|
||
|
||
expect(result).toHaveLength(1);
|
||
expect(result[0].subject).toBe('物理');
|
||
expect(created.map((row) => row.subject)).toEqual(['物理']);
|
||
});
|
||
|
||
it('keeps non-subject roles as a single row with empty-string subject and rejects duplicate roles', async () => {
|
||
const classTeacherRepo = {
|
||
findOne: jest.fn().mockResolvedValue(null),
|
||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 3, ...value })),
|
||
save: jest.fn().mockResolvedValue(undefined),
|
||
find: jest.fn().mockResolvedValue([]),
|
||
};
|
||
const classRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }), update: jest.fn() };
|
||
const service = createService(classRepo, classTeacherRepo);
|
||
|
||
const result = await service.addTeacher(1, {
|
||
userId: 11,
|
||
roleType: TeacherRoleType.HEAD_TEACHER,
|
||
subjects: ['数学'],
|
||
});
|
||
expect(result).toHaveLength(1);
|
||
expect(result[0].subject).toBe('');
|
||
expect(classTeacherRepo.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ classId: 1, userId: 11, roleType: 'head_teacher', subject: '' }),
|
||
);
|
||
|
||
classTeacherRepo.findOne.mockResolvedValue({ id: 3 });
|
||
await expect(
|
||
service.addTeacher(1, { userId: 11, roleType: TeacherRoleType.HEAD_TEACHER }),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
});
|
||
|
||
it('returns getTeachers rows as one row per subject', async () => {
|
||
const rows = [
|
||
{ id: 1, classId: 5, userId: 10, roleType: 'subject_teacher', subject: '数学', user: { id: 10 } },
|
||
{ id: 2, classId: 5, userId: 10, roleType: 'subject_teacher', subject: '英语', user: { id: 10 } },
|
||
{ id: 3, classId: 5, userId: 11, roleType: 'head_teacher', subject: null, user: { id: 11 } },
|
||
];
|
||
const service = createService({}, { find: jest.fn().mockResolvedValue(rows) });
|
||
|
||
const result = await service.getTeachers(5);
|
||
|
||
expect(result).toHaveLength(3);
|
||
expect(result[0]).toEqual(
|
||
expect.objectContaining({
|
||
id: 1,
|
||
userId: 10,
|
||
roleType: 'subject_teacher',
|
||
subject: '数学',
|
||
subjects: ['数学'],
|
||
}),
|
||
);
|
||
expect(result[1]).toEqual(
|
||
expect.objectContaining({
|
||
id: 2,
|
||
userId: 10,
|
||
roleType: 'subject_teacher',
|
||
subject: '英语',
|
||
subjects: ['英语'],
|
||
}),
|
||
);
|
||
expect(result[2]).toEqual(
|
||
expect.objectContaining({ userId: 11, roleType: 'head_teacher', subject: null, subjects: [] }),
|
||
);
|
||
});
|
||
|
||
it('returns findOne teachers as one row per subject', async () => {
|
||
const classRepo = {
|
||
findOne: jest.fn().mockResolvedValueOnce({ id: 5 }).mockResolvedValue({ id: 5 }),
|
||
};
|
||
const classStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||
const classTeacherRepo = {
|
||
find: jest.fn().mockResolvedValue([
|
||
{ id: 1, classId: 5, userId: 10, roleType: 'subject_teacher', subject: '数学', user: { name: '张老师', username: 'zhang' } },
|
||
{ id: 2, classId: 5, userId: 10, roleType: 'subject_teacher', subject: '英语', user: { name: '张老师', username: 'zhang' } },
|
||
]),
|
||
};
|
||
const service = createService(classRepo, classTeacherRepo, classStudentRepo);
|
||
|
||
const detail = await service.findOne(5);
|
||
|
||
expect(detail.teachers).toEqual([
|
||
expect.objectContaining({
|
||
userId: 10,
|
||
name: '张老师',
|
||
username: 'zhang',
|
||
roleType: 'subject_teacher',
|
||
subject: '数学',
|
||
subjects: ['数学'],
|
||
}),
|
||
expect.objectContaining({
|
||
userId: 10,
|
||
name: '张老师',
|
||
username: 'zhang',
|
||
roleType: 'subject_teacher',
|
||
subject: '英语',
|
||
subjects: ['英语'],
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('expands create teachers into one row per subject', async () => {
|
||
const created: ClassTeacher[] = [];
|
||
const classRepo = {
|
||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 9, ...value })),
|
||
save: jest.fn().mockResolvedValue({ id: 9 }),
|
||
findOne: jest.fn().mockResolvedValue({ id: 9 }),
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
const classTeacherRepo = {
|
||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: created.length + 1, ...value })),
|
||
save: jest.fn().mockImplementation(async (entries: ClassTeacher | ClassTeacher[]) => {
|
||
created.push(...(Array.isArray(entries) ? entries : [entries]));
|
||
return entries;
|
||
}),
|
||
find: jest.fn().mockResolvedValue([]),
|
||
};
|
||
const classStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||
const service = createService(classRepo, classTeacherRepo, classStudentRepo, {
|
||
transaction: async (fn: (m: unknown) => unknown) =>
|
||
fn(entityAwareManager(classRepo, classTeacherRepo, classStudentRepo)),
|
||
});
|
||
|
||
await service.create({
|
||
name: '高三1班',
|
||
code: 'G3-1',
|
||
classType: 'culture',
|
||
teachers: [
|
||
{ userId: 10, roleType: TeacherRoleType.SUBJECT_TEACHER, subjects: ['数学', '英语'] },
|
||
{ userId: 11, roleType: TeacherRoleType.HEAD_TEACHER, subjects: ['不应存储'] },
|
||
],
|
||
});
|
||
|
||
expect(created.map((row) => ({ userId: row.userId, roleType: row.roleType, subject: row.subject }))).toEqual([
|
||
{ userId: 10, roleType: 'subject_teacher', subject: '数学' },
|
||
{ userId: 10, roleType: 'subject_teacher', subject: '英语' },
|
||
{ userId: 11, roleType: 'head_teacher', subject: '' },
|
||
]);
|
||
});
|
||
|
||
it('rejects create with subject_teacher missing subjects', async () => {
|
||
const classRepo = {
|
||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 9, ...value })),
|
||
save: jest.fn().mockResolvedValue({ id: 9 }),
|
||
findOne: jest.fn().mockResolvedValue({ id: 9 }),
|
||
update: jest.fn(),
|
||
};
|
||
const classTeacherRepo = {
|
||
create: jest.fn(),
|
||
save: jest.fn(),
|
||
find: jest.fn(),
|
||
};
|
||
const service = createService(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) });
|
||
|
||
await expect(
|
||
service.create({
|
||
name: '高三1班',
|
||
code: 'G3-1',
|
||
classType: 'culture',
|
||
teachers: [{ userId: 10, roleType: TeacherRoleType.SUBJECT_TEACHER }],
|
||
}),
|
||
).rejects.toBeInstanceOf(BadRequestException);
|
||
// 预校验发生在写班级之前,避免 400 留下孤儿班级
|
||
expect(classRepo.save).not.toHaveBeenCalled();
|
||
expect(classTeacherRepo.save).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('falls back to subject when subjects is empty or whitespace-only in create', async () => {
|
||
const created: ClassTeacher[] = [];
|
||
const classRepo = {
|
||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 9, ...value })),
|
||
save: jest.fn().mockResolvedValue({ id: 9 }),
|
||
findOne: jest.fn().mockResolvedValue({ id: 9 }),
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
const classTeacherRepo = {
|
||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: created.length + 1, ...value })),
|
||
save: jest.fn().mockImplementation(async (entries: ClassTeacher | ClassTeacher[]) => {
|
||
created.push(...(Array.isArray(entries) ? entries : [entries]));
|
||
return entries;
|
||
}),
|
||
find: jest.fn().mockResolvedValue([]),
|
||
};
|
||
const service = createService(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) }, {
|
||
transaction: async (fn: (m: unknown) => unknown) =>
|
||
fn(entityAwareManager(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) })),
|
||
});
|
||
|
||
await service.create({
|
||
name: '高三1班',
|
||
code: 'G3-1',
|
||
classType: 'culture',
|
||
teachers: [
|
||
{ userId: 10, roleType: TeacherRoleType.SUBJECT_TEACHER, subjects: [' '], subject: '数学' },
|
||
{ userId: 11, roleType: TeacherRoleType.SUBJECT_TEACHER, subjects: [], subject: '英语' },
|
||
],
|
||
});
|
||
|
||
expect(created.map((row) => row.subject)).toEqual(['数学', '英语']);
|
||
});
|
||
});
|