Files
gongxue-base/apps/server/src/classes/classes.teacher-subjects.spec.ts
wangziqi 122f3843a2 fix(server): 班级创建事务化、花名册同名新生兜底修正、迁移超长科目备份
- create() 班级/学生/教师写入整体包事务,避免教师失败留下孤儿班级
- 花名册提交:行内带标识的同名新生不再复用本批姓名兜底(避免被误判冲突丢弃,与预览一致)
- 迁移:超长科目截断前备份原值,且备份表确保存在(全新部署不因缺表启动失败)
- workbook rels 解析真实工作表路径,防自定义文件名绕过 sheet/行数预扫描
- keepPlus 完整正则对齐注释意图(+ 86 前缀保留);formatDate 用 UTC getter
- main.ts 大 JSON 路由前缀与限制提取为常量并注明耦合点
2026-08-11 12:38:24 +08:00

358 lines
14 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 } 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(['数学', '英语']);
});
});