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>(getRepositoryToken(Role)); const userRepo = app.get>(getRepositoryToken(User)); const studentRepo = app.get>(getRepositoryToken(Student)); const mappingRepo = app.get>( getRepositoryToken(StudentDingMapping), ); const organizationRepo = app.get>(getRepositoryToken(Organization)); const attendanceRepo = app.get>( 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> = 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, right: Pick) => 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' }), ]); }); });