332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import type { INestApplication } from '@nestjs/common';
|
|
import { Test } from '@nestjs/testing';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import request from 'supertest';
|
|
import type { Repository } from 'typeorm';
|
|
import { AppModule } from '../app.module';
|
|
import type { DingTalkAttendanceResult } from '../integration/dingtalk.service';
|
|
import { DingTalkService } from '../integration/dingtalk.service';
|
|
import {
|
|
AttendanceRecord,
|
|
Organization,
|
|
Role,
|
|
Student,
|
|
StudentDingMapping,
|
|
User,
|
|
} from '../entities';
|
|
import { createStudentImportTemplateWorkbook } from '../students/student-import';
|
|
|
|
const LESSON_DATE = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Shanghai',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(new Date());
|
|
const STUDENT_A_DING_ID = 'integration-student-a';
|
|
const STUDENT_B_DING_ID = 'integration-student-b';
|
|
|
|
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
|
|
|
|
function chinaWeekDay(date: string): number {
|
|
const day = new Date(`${date}T00:00:00+08:00`).getDay();
|
|
return day === 0 ? 7 : day;
|
|
}
|
|
|
|
function attendanceResult(
|
|
userId: string,
|
|
checkId: string,
|
|
actualCheckTime: string,
|
|
): DingTalkAttendanceResult {
|
|
return {
|
|
userId,
|
|
userName: '',
|
|
workDate: LESSON_DATE,
|
|
timeResult: 'Normal',
|
|
locationResult: 'Normal',
|
|
planCheckTime: `${LESSON_DATE}T00:00:00+08:00`,
|
|
actualCheckTime,
|
|
checkId,
|
|
checkType: 'OnDuty',
|
|
sourceType: 'ATM',
|
|
deviceName: '集成测试考勤机',
|
|
deviceId: 'integration-device',
|
|
};
|
|
}
|
|
|
|
describe('attendance workflow integration', () => {
|
|
let app: INestApplication;
|
|
let adminToken: string;
|
|
let teacherToken: string;
|
|
let mockedPunches: DingTalkAttendanceResult[];
|
|
const originalEnv = {
|
|
DB_TYPE: process.env.DB_TYPE,
|
|
DB_DATABASE: process.env.DB_DATABASE,
|
|
DB_SYNCHRONIZE: process.env.DB_SYNCHRONIZE,
|
|
SEED_DEV: process.env.SEED_DEV,
|
|
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
process.env.DB_TYPE = 'sqlite';
|
|
process.env.DB_DATABASE = ':memory:';
|
|
process.env.DB_SYNCHRONIZE = 'true';
|
|
process.env.SEED_DEV = 'true';
|
|
process.env.ADMIN_PASSWORD = 'admin123';
|
|
|
|
mockedPunches = [];
|
|
const dingTalk = {
|
|
fetchAttendanceResults: jest.fn(async () => mockedPunches),
|
|
};
|
|
|
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
|
.overrideProvider(DingTalkService)
|
|
.useValue(dingTalk)
|
|
.compile();
|
|
|
|
app = moduleRef.createNestApplication();
|
|
app.setGlobalPrefix('api');
|
|
await app.init();
|
|
|
|
const login = await request(app.getHttpServer())
|
|
.post('/api/auth/login')
|
|
.send({ username: 'admin', password: 'admin123' })
|
|
.expect(201);
|
|
adminToken = login.body.access_token;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app?.close();
|
|
for (const [key, value] of Object.entries(originalEnv)) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
});
|
|
|
|
it('imports students, builds a teacher class schedule, refreshes punches, and scopes reads', async () => {
|
|
const roleRepo = app.get<Repository<Role>>(getRepositoryToken(Role));
|
|
const userRepo = app.get<Repository<User>>(getRepositoryToken(User));
|
|
const studentRepo = app.get<Repository<Student>>(getRepositoryToken(Student));
|
|
const mappingRepo = app.get<Repository<StudentDingMapping>>(
|
|
getRepositoryToken(StudentDingMapping),
|
|
);
|
|
const organizationRepo = app.get<Repository<Organization>>(getRepositoryToken(Organization));
|
|
const attendanceRepo = app.get<Repository<AttendanceRecord>>(
|
|
getRepositoryToken(AttendanceRecord),
|
|
);
|
|
|
|
const teacherRole = await roleRepo.findOneByOrFail({ code: 'teacher' });
|
|
const teacherCreate = await request(app.getHttpServer())
|
|
.post('/api/rbac/users')
|
|
.set(auth(adminToken))
|
|
.send({
|
|
username: 'integration-teacher',
|
|
password: 'teacher123',
|
|
name: '集成测试任课教师',
|
|
roleIds: [teacherRole.id],
|
|
})
|
|
.expect(201);
|
|
expect(teacherCreate.body.message).toBe('用户创建成功');
|
|
|
|
const teacher = await userRepo.findOneByOrFail({ username: 'integration-teacher' });
|
|
const teacherId = teacher.id;
|
|
const teacherLogin = await request(app.getHttpServer())
|
|
.post('/api/auth/login')
|
|
.send({ username: 'integration-teacher', password: 'teacher123' })
|
|
.expect(201);
|
|
teacherToken = teacherLogin.body.access_token;
|
|
|
|
const host = await organizationRepo.findOneByOrFail({ isHost: true, status: 'active' });
|
|
const workbook = createStudentImportTemplateWorkbook();
|
|
const sheet = workbook.getWorksheet('学生基础+档案+录取')!;
|
|
sheet.spliceRows(2, 1);
|
|
sheet.addRow({
|
|
phone: '13800000001',
|
|
name: '集成学生甲',
|
|
studentNo: 'IT001',
|
|
organization: host.name,
|
|
});
|
|
sheet.addRow({
|
|
phone: '13800000002',
|
|
name: '集成学生乙',
|
|
studentNo: 'IT002',
|
|
organization: host.name,
|
|
});
|
|
const workbookBuffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
|
|
|
const importResult = await request(app.getHttpServer())
|
|
.post('/api/students/import')
|
|
.set(auth(adminToken))
|
|
.attach('file', workbookBuffer, {
|
|
filename: 'attendance-workflow-students.xlsx',
|
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
})
|
|
.expect(201);
|
|
expect(importResult.body).toMatchObject({ imported: 2, skipped: 0 });
|
|
|
|
const [studentA, studentB] = await Promise.all([
|
|
studentRepo.findOneByOrFail({ phone: '13800000001' }),
|
|
studentRepo.findOneByOrFail({ phone: '13800000002' }),
|
|
]);
|
|
await mappingRepo.save([
|
|
mappingRepo.create({ dingUserId: STUDENT_A_DING_ID, studentId: studentA.id }),
|
|
mappingRepo.create({ dingUserId: STUDENT_B_DING_ID, studentId: studentB.id }),
|
|
]);
|
|
|
|
const classResult = await request(app.getHttpServer())
|
|
.post('/api/classes')
|
|
.set(auth(adminToken))
|
|
.send({
|
|
name: '集成考勤班',
|
|
code: 'ATTENDANCE-INTEGRATION',
|
|
classType: 'culture',
|
|
status: 'active',
|
|
startDate: LESSON_DATE,
|
|
endDate: LESSON_DATE,
|
|
})
|
|
.expect(201);
|
|
const classId = classResult.body.id;
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/api/classes/${classId}/students`)
|
|
.set(auth(adminToken))
|
|
.send({ studentIds: [studentA.id, studentB.id] })
|
|
.expect(201)
|
|
.expect(({ body }) => expect(body).toMatchObject({ added: 2, skipped: 0 }));
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/api/classes/${classId}/teachers`)
|
|
.set(auth(adminToken))
|
|
.send({ userId: teacherId, roleType: 'subject_teacher', subject: '语文' })
|
|
.expect(201);
|
|
|
|
const classroomResult = await request(app.getHttpServer())
|
|
.post('/api/classrooms')
|
|
.set(auth(adminToken))
|
|
.send({ name: '集成测试教室', building: '测试楼', floor: 1, capacity: 30, roomType: '小' })
|
|
.expect(201);
|
|
|
|
const scheduleResult = await request(app.getHttpServer())
|
|
.post('/api/class-schedules')
|
|
.set(auth(adminToken))
|
|
.send({
|
|
classId,
|
|
classroomId: classroomResult.body.id,
|
|
weekDay: chinaWeekDay(LESSON_DATE),
|
|
startTime: '00:00',
|
|
endTime: '23:59',
|
|
attendanceAdvanceMinutes: 0,
|
|
startDate: LESSON_DATE,
|
|
endDate: LESSON_DATE,
|
|
subject: '语文',
|
|
teacherId,
|
|
scheduleType: 'INTERNAL',
|
|
})
|
|
.expect(201);
|
|
const scheduleId = scheduleResult.body.id;
|
|
|
|
const initialPull = await request(app.getHttpServer())
|
|
.post(`/api/attendance-lessons/schedules/${scheduleId}/pull`)
|
|
.set(auth(teacherToken))
|
|
.send({ date: LESSON_DATE })
|
|
.expect(201);
|
|
expect(initialPull.body.records).toHaveLength(2);
|
|
expect(initialPull.body.records.map((record: AttendanceRecord) => record.status)).toEqual([
|
|
'pending',
|
|
'pending',
|
|
]);
|
|
|
|
const studentARecord = initialPull.body.records.find(
|
|
(record: AttendanceRecord) => record.studentId === studentA.id,
|
|
);
|
|
await request(app.getHttpServer())
|
|
.put(`/api/attendance-records/${studentARecord.id}`)
|
|
.set(auth(teacherToken))
|
|
.send({ status: 'absent', remark: '教师本地覆盖' })
|
|
.expect(200)
|
|
.expect(({ body }) => expect(body).toMatchObject({ status: 'absent', source: 'manual' }));
|
|
|
|
mockedPunches = [
|
|
attendanceResult(STUDENT_A_DING_ID, 'integration-check-a', `${LESSON_DATE}T01:00:00.000Z`),
|
|
attendanceResult(STUDENT_B_DING_ID, 'integration-check-b', `${LESSON_DATE}T01:05:00.000Z`),
|
|
];
|
|
|
|
const refreshed = await request(app.getHttpServer())
|
|
.post(`/api/attendance-lessons/schedules/${scheduleId}/pull`)
|
|
.set(auth(teacherToken))
|
|
.send({ date: LESSON_DATE })
|
|
.expect(201);
|
|
expect(refreshed.body.records).toHaveLength(2);
|
|
expect(
|
|
refreshed.body.records.find((record: AttendanceRecord) => record.studentId === studentA.id),
|
|
).toMatchObject({ status: 'absent', source: 'manual', remark: '教师本地覆盖' });
|
|
expect(
|
|
refreshed.body.records.find((record: AttendanceRecord) => record.studentId === studentB.id),
|
|
).toMatchObject({ status: 'present', source: 'dingtalk', punchSource: 'ATM' });
|
|
|
|
const teacherRecords = await request(app.getHttpServer())
|
|
.get(
|
|
`/api/attendance-records?classId=${classId}&dateFrom=${LESSON_DATE}&dateTo=${LESSON_DATE}`,
|
|
)
|
|
.set(auth(teacherToken))
|
|
.expect(200);
|
|
expect(teacherRecords.body.list).toHaveLength(2);
|
|
const teacherView: Array<Pick<AttendanceRecord, 'studentId' | 'status' | 'source'>> =
|
|
teacherRecords.body.list
|
|
.map((record: AttendanceRecord) => ({
|
|
studentId: record.studentId,
|
|
status: record.status,
|
|
source: record.source,
|
|
}))
|
|
.sort((left, right) => left.studentId - right.studentId);
|
|
expect(teacherView).toEqual([
|
|
{ studentId: studentA.id, status: 'absent', source: 'manual' },
|
|
{ studentId: studentB.id, status: 'present', source: 'dingtalk' },
|
|
]);
|
|
|
|
const adminRecords = await request(app.getHttpServer())
|
|
.get(
|
|
`/api/attendance-records?classId=${classId}&dateFrom=${LESSON_DATE}&dateTo=${LESSON_DATE}`,
|
|
)
|
|
.set(auth(adminToken))
|
|
.expect(200);
|
|
expect(adminRecords.body.list).toHaveLength(2);
|
|
expect(
|
|
adminRecords.body.list
|
|
.map((record: AttendanceRecord) => ({
|
|
studentId: record.studentId,
|
|
status: record.status,
|
|
source: record.source,
|
|
}))
|
|
.sort(
|
|
(left: Pick<AttendanceRecord, 'studentId'>, right: Pick<AttendanceRecord, 'studentId'>) =>
|
|
left.studentId - right.studentId,
|
|
),
|
|
).toEqual(teacherView);
|
|
|
|
const unassignedClass = await request(app.getHttpServer())
|
|
.post('/api/classes')
|
|
.set(auth(adminToken))
|
|
.send({
|
|
name: '未分配教师班级',
|
|
code: 'UNASSIGNED-INTEGRATION',
|
|
classType: 'culture',
|
|
status: 'active',
|
|
})
|
|
.expect(201);
|
|
await request(app.getHttpServer())
|
|
.get(`/api/attendance-records?classId=${unassignedClass.body.id}`)
|
|
.set(auth(teacherToken))
|
|
.expect(400);
|
|
|
|
const persisted = await attendanceRepo.find({
|
|
where: { classId },
|
|
order: { studentId: 'ASC' },
|
|
});
|
|
expect(persisted).toHaveLength(2);
|
|
expect(persisted).toEqual([
|
|
expect.objectContaining({ studentId: studentA.id, status: 'absent', source: 'manual' }),
|
|
expect.objectContaining({ studentId: studentB.id, status: 'present', source: 'dingtalk' }),
|
|
]);
|
|
});
|
|
});
|