import { BadRequestException, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';

const createService = () => {
  const attendanceRepo = {
    create: jest.fn((value: Record<string, unknown>) => value),
    find: jest.fn(),
    findOne: jest.fn(),
    save: jest.fn(async (records: unknown) => records),
    remove: jest.fn(async (record: unknown) => record),
    count: jest.fn(),
  };
  const dingRawRepo = { find: jest.fn() };
  const scheduleRepo = { findOne: jest.fn() };
  const classStudentRepo = { find: jest.fn() };
  const sessionRepo = {
    findOne: jest.fn(),
    create: jest.fn((value: Record<string, unknown>) => ({ id: 90, ...value })),
    save: jest.fn(async (value: unknown) => value),
  };
  const attendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) };
  const dataSource = {
    transaction: jest.fn(
      async (cb: (manager: { getRepository: jest.Mock }) => Promise<unknown>) => {
        const managerGetRepo = jest.fn((entity: { name: string }) => {
          if (entity.name === AttendanceSession.name) return sessionRepo;
          if (entity.name === AttendanceRecord.name) return attendanceRepo;
          return {};
        });
        return cb({ getRepository: managerGetRepo });
      },
    ),
  };
  const service = new AttendanceService(
    attendanceRepo as never,
    dingRawRepo as never,
    {} as never,
    {} as never,
    scheduleRepo as never,
    classStudentRepo as never,
    {} as never,
    {} as never,
    sessionRepo as never,
    attendanceDeviceRepo as never,
    {} as never,
    dataSource as unknown as DataSource,
  );
  return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, attendanceDeviceRepo, dataSource };
};

const endedSchedule = {
  id: 4,
  classId: 8,
  weekDay: 6,
  startTime: '09:00',
  endTime: '10:00',
  startDate: '2026-07-01',
  endDate: '2026-07-31',
  subject: '\u6570\u5B66',
  status: 'active',
  scheduleType: 'INTERNAL',
  attendanceAdvanceMinutes: 30,
};

describe('AttendanceService \u2014 DingTalk course attendance', () => {
  it('creates one course session from DingTalk results after the lesson', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue(null);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
      { studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
      { studentId: 3, student: { id: 3, name: '\u738B\u4E94' } },
      { studentId: 4, student: { id: 4, name: '\u8D75\u516D' } },
    ]);
    dingRawRepo.find.mockResolvedValue([
      {
        matchedStudentId: 1,
        attendanceType: 'OnDuty',
        timeResult: 'Normal',
        checkInTime: new Date('2026-07-11T08:55:00+08:00'),
        punchSource: 'ATM',
        punchDeviceName: '东门考勤机',
        punchDeviceId: 'ATM-01',
      },
      {
        matchedStudentId: 2,
        attendanceType: 'OnDuty',
        timeResult: 'Late',
        checkInTime: new Date('2026-07-11T09:05:00+08:00'),
      },
      { matchedStudentId: 3, attendanceType: 'OnDuty', timeResult: 'NotSigned' },
    ]);

    const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);

    expect(sessionRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({
        scheduleId: 4,
        classId: 8,
        lessonDate: '2026-07-11',
        status: 'in_progress',
      }),
    );
    expect(attendanceRepo.save).toHaveBeenCalledWith([
      expect.objectContaining({
        studentId: 1,
        status: 'present',
        source: 'dingtalk',
        punchSource: 'ATM',
        punchDeviceName: '东门考勤机',
        punchDeviceId: 'ATM-01',
        punchTime: new Date('2026-07-11T08:55:00+08:00'),
      }),
      expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
      expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
      expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
    ]);
    expect(result.records).toHaveLength(4);
  });

  it('finalizes missing punches as absent and completes the session', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue(null);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '张三' } },
    ]);
    dingRawRepo.find.mockResolvedValue([]);

    const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);

    expect(attendanceRepo.save).toHaveBeenCalledWith([
      expect.objectContaining({ studentId: 1, status: 'absent' }),
    ]);
    expect(sessionRepo.save).toHaveBeenLastCalledWith(
      expect.objectContaining({ status: 'completed', completedBy: 21 }),
    );
    expect(result.session.status).toBe('completed');
  });

  it('returns student relations after the first pull', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue(null);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '张三' } },
    ]);
    dingRawRepo.find.mockResolvedValue([]);

    const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);

    expect(attendanceRepo.save).toHaveBeenCalledWith([
      expect.objectContaining({ student: { id: 1, name: '张三' } }),
    ]);
    expect(result.records[0].student.name).toBe('张三');
  });

  it('uses the DingTalk punch nearest to this lesson start when a student has multiple shifts', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue(null);
    classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } }]);
    dingRawRepo.find.mockResolvedValue([
      {
        matchedStudentId: 1,
        attendanceType: 'OnDuty',
        timeResult: 'Late',
        checkInTime: new Date('2026-07-11T02:00:00+08:00'),
      },
      {
        matchedStudentId: 1,
        attendanceType: 'OnDuty',
        timeResult: 'Normal',
        checkInTime: new Date('2026-07-11T08:55:00+08:00'),
      },
    ]);

    await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);

    expect(attendanceRepo.save).toHaveBeenCalledWith([
      expect.objectContaining({ studentId: 1, status: 'present' }),
    ]);
  });

  it('counts both OnDuty and OffDuty punches only inside the configured window', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue({ ...endedSchedule, attendanceAdvanceMinutes: 20 });
    sessionRepo.findOne.mockResolvedValue(null);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '张三' } },
      { studentId: 2, student: { id: 2, name: '李四' } },
      { studentId: 3, student: { id: 3, name: '王五' } },
    ]);
    dingRawRepo.find.mockResolvedValue([
      { matchedStudentId: 1, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T08:40:00+08:00') },
      { matchedStudentId: 2, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:00+08:00') },
      { matchedStudentId: 3, attendanceType: 'OnDuty', checkInTime: new Date('2026-07-11T08:39:59+08:00') },
      { matchedStudentId: 3, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:01+08:00') },
    ]);

    await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);

    expect(attendanceRepo.save).toHaveBeenCalledWith([
      expect.objectContaining({ studentId: 1, status: 'present' }),
      expect.objectContaining({ studentId: 2, status: 'present' }),
      expect.objectContaining({ studentId: 3, status: 'absent' }),
    ]);
  });

  it('expands import dates when the pre-class window crosses midnight', () => {
    const { service } = createService();
    expect(service.getLessonAttendanceImportDateRange(
      { startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
      '2026-07-11',
    )).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
  });

  it('creates local attendance after the lesson starts', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    const now = new Date();
    const weekDay = now.getDay() === 0 ? 7 : now.getDay();
    scheduleRepo.findOne.mockResolvedValue({
      ...endedSchedule,
      weekDay,
      startTime: '00:00',
      endTime: '23:59',
      startDate: '2026-01-01',
      endDate: '2026-12-31',
    });
    sessionRepo.findOne.mockResolvedValue(null);
    classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
    dingRawRepo.find.mockResolvedValue([]);
    const today = [
      now.getFullYear(),
      String(now.getMonth() + 1).padStart(2, '0'),
      String(now.getDate()).padStart(2, '0'),
    ].join('-');

    const result = await service.createLessonAttendanceFromDingTalk(4, today, 21);

    expect(attendanceRepo.save).toHaveBeenCalled();
    expect(result.records).toHaveLength(1);
  });

  it('refreshes a completed session when DingTalk punches arrive late', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      scheduleId: 4,
      classId: 8,
      lessonDate: '2026-07-11',
      status: 'completed',
    });
    attendanceRepo.find.mockResolvedValue([
      { id: 1, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
    ]);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '张三' } },
    ]);
    dingRawRepo.find.mockResolvedValue([
      {
        matchedStudentId: 1,
        attendanceType: 'OnDuty',
        checkInTime: new Date('2026-07-11T08:55:00+08:00'),
      },
    ]);

    const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);

    expect(attendanceRepo.save).toHaveBeenCalledWith([
      expect.objectContaining({ studentId: 1, status: 'present', remark: null }),
    ]);
    expect(sessionRepo.save).not.toHaveBeenCalled();
    expect(result.session.status).toBe('completed');
  });

  it('refreshes an in_progress session from latest DingTalk data', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      scheduleId: 4,
      classId: 8,
      lessonDate: '2026-07-11',
      status: 'in_progress',
    });
    attendanceRepo.find.mockResolvedValue([
      { id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
      { id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
    ]);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
      { studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
    ]);
    dingRawRepo.find.mockResolvedValue([
      { matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
      { matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
    ]);

    const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);

    expect(sessionRepo.create).not.toHaveBeenCalled();
    expect(attendanceRepo.save).toHaveBeenCalled();

    const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
    const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
    expect(savedRecords).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ studentId: 1, status: 'present' }),
        expect.objectContaining({ studentId: 2, status: 'present' }),
      ]),
    );
    expect(result.records).toHaveLength(2);
  });


  it('restores students missing from an existing empty session', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      scheduleId: 4,
      classId: 8,
      lessonDate: '2026-07-11',
      status: 'in_progress',
    });
    attendanceRepo.find.mockResolvedValue([]);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '张三' } },
      { studentId: 2, student: { id: 2, name: '李四' } },
    ]);
    dingRawRepo.find.mockResolvedValue([]);

    const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);

    expect(attendanceRepo.save).toHaveBeenCalledWith([
      expect.objectContaining({ studentId: 1, student: { id: 1, name: '张三' } }),
      expect.objectContaining({ studentId: 2, student: { id: 2, name: '李四' } }),
    ]);
    expect(result.records).toHaveLength(2);
  });
  it('preserves manually corrected records when refreshing an in_progress session', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      scheduleId: 4,
      classId: 8,
      lessonDate: '2026-07-11',
      status: 'in_progress',
    });
    attendanceRepo.find.mockResolvedValue([
      { id: 101, studentId: 1, attendanceSessionId: 90, status: 'leave', source: 'manual' },
      { id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
    ]);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
      { studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
    ]);
    dingRawRepo.find.mockResolvedValue([
      { matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
      { matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
    ]);

    const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);

    const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
    const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
    expect(savedRecords).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ studentId: 1, status: 'leave' }),
        expect.objectContaining({ studentId: 2, status: 'present' }),
      ]),
    );
    expect(result.records).toHaveLength(2);
  });

  it('final settlement overrides interim manual status using the final punch result', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      scheduleId: 4,
      classId: 8,
      lessonDate: '2026-07-11',
      status: 'in_progress',
    });
    attendanceRepo.find.mockResolvedValue([
      { id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
    ]);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '张三' } },
    ]);
    dingRawRepo.find.mockResolvedValue([]);

    await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);

    expect(attendanceRepo.save).toHaveBeenCalledWith([
      expect.objectContaining({ studentId: 1, status: 'absent' }),
    ]);
  });

  it('rejects completion when pending records exist', async () => {
    const { service, sessionRepo, attendanceRepo } = createService();
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      scheduleId: 4,
      classId: 8,
      status: 'in_progress',
    });
    attendanceRepo.count.mockResolvedValue(1);

    await expect(service.completeLessonAttendance(90, 21)).rejects.toBeInstanceOf(
      BadRequestException,
    );
  });

  it('completes a pulled attendance session after teacher review', async () => {
    const { service, sessionRepo, attendanceRepo } = createService();
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      scheduleId: 4,
      classId: 8,
      status: 'in_progress',
    });
    attendanceRepo.count.mockResolvedValue(0);
    attendanceRepo.find.mockResolvedValue([
      { id: 1, status: 'present' },
      { id: 2, status: 'absent' },
    ]);

    const result = await service.completeLessonAttendance(90, 21);

    expect(sessionRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ id: 90, status: 'completed', completedBy: 21 }),
    );
    expect(result.session.status).toBe('completed');
  });

  it('throws when querying a missing schedule attendance session', async () => {
    const { service, scheduleRepo } = createService();
    scheduleRepo.findOne.mockResolvedValue(null);
    await expect(service.getLessonAttendance(999, '2026-07-11')).rejects.toBeInstanceOf(
      NotFoundException,
    );
  });

  it('update() marks record source as manual so refresh preserves the correction', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      scheduleId: 4,
      classId: 8,
      lessonDate: '2026-07-11',
      status: 'in_progress',
    });
    // Initial state: dingtalk-sourced record
    attendanceRepo.findOne.mockResolvedValue({
      id: 101,
      studentId: 1,
      attendanceSessionId: 90,
      status: 'present',
      source: 'dingtalk',
    });
    attendanceRepo.find.mockResolvedValue([
      { id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'manual' },
      { id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
    ]);
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '张三' } },
      { studentId: 2, student: { id: 2, name: '李四' } },
    ]);
    dingRawRepo.find.mockResolvedValue([
      { matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
      { matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
    ]);

    // Step 1: update the record to absent via generic update()
    const updated = await service.update(101, { status: 'absent' });
    expect(updated.source).toBe('manual');

    // Step 2: refresh in_progress session — manual record status must stay absent
    const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);

    const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
      (attendanceRepo.save as jest.Mock).mock.calls.length - 1
    ][0] as Array<{ studentId: number; status: string }>;
    expect(savedRecords).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ studentId: 1, status: 'absent' }),
        expect.objectContaining({ studentId: 2, status: 'present' }),
      ]),
    );
  });

  it('recovers from concurrent unique constraint on first session creation', async () => {
    const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
      createService();
    scheduleRepo.findOne.mockResolvedValue(endedSchedule);
    sessionRepo.findOne
      .mockResolvedValueOnce(null) // first check: no existing session
      .mockResolvedValueOnce({
        // recovery: the winning session
        id: 77,
        scheduleId: 4,
        classId: 8,
        lessonDate: '2026-07-11',
        status: 'in_progress',
      });
    // Simulate unique constraint on save
    sessionRepo.save.mockRejectedValueOnce(
      Object.assign(new Error('UNIQUE constraint failed'), {
        code: 'SQLITE_CONSTRAINT',
        errno: undefined,
      }),
    );
    classStudentRepo.find.mockResolvedValue([
      { studentId: 1, student: { id: 1, name: '张三' } },
    ]);
    dingRawRepo.find.mockResolvedValue([]);
    attendanceRepo.find.mockResolvedValue([
      { id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
    ]);

    const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);

    expect(result.session.id).toBe(77);
    expect(result.records).toHaveLength(1);
  });

  it('completeLessonAttendance runs inside a transaction', async () => {
    const { service, sessionRepo, attendanceRepo, dataSource } = createService();
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      scheduleId: 4,
      classId: 8,
      status: 'in_progress',
    });
    attendanceRepo.count.mockResolvedValue(0);
    attendanceRepo.find.mockResolvedValue([
      { id: 1, status: 'present' },
    ]);

    await service.completeLessonAttendance(90, 21);

    expect(dataSource.transaction).toHaveBeenCalled();
  });

  it('update() throws when the parent session is completed', async () => {
    const { service, attendanceRepo, sessionRepo } = createService();
    attendanceRepo.findOne.mockResolvedValue({
      id: 101,
      attendanceSessionId: 90,
      status: 'present',
    });
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      status: 'completed',
    });

    await expect(service.update(101, { status: 'absent' })).rejects.toBeInstanceOf(
      BadRequestException,
    );
  });

  it('remove() throws when the parent session is completed', async () => {
    const { service, attendanceRepo, sessionRepo } = createService();
    attendanceRepo.findOne.mockResolvedValue({
      id: 101,
      attendanceSessionId: 90,
    });
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      status: 'completed',
    });

    await expect(service.remove(101)).rejects.toBeInstanceOf(BadRequestException);
  });

  it('update() allows modification when the parent session is in_progress', async () => {
    const { service, attendanceRepo, sessionRepo } = createService();
    attendanceRepo.findOne.mockResolvedValue({
      id: 101,
      attendanceSessionId: 90,
      status: 'present',
    });
    sessionRepo.findOne.mockResolvedValue({
      id: 90,
      status: 'in_progress',
    });

    const result = await service.update(101, { status: 'absent' });
    expect(result.source).toBe('manual');
  });
});

describe('AttendanceService — attendance window boundaries', () => {
  it('crosses calendar boundaries only when the window requires it', () => {
    const { service } = createService();
    expect(service.getLessonAttendanceImportDateRange(
      { startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 }, '2026-07-13',
    )).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
    expect(service.getLessonAttendanceImportDateRange(
      { startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 }, '2026-07-13',
    )).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
    expect(service.getLessonAttendanceImportDateRange(
      { startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 }, '2026-07-13',
    )).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
  });

  it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
    const originalTz = process.env.TZ;
    process.env.TZ = 'UTC';
    jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
    try {
      const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } = createService();
      scheduleRepo.findOne.mockResolvedValue({
        ...endedSchedule, weekDay: 1, startTime: '08:30', endTime: '10:00',
        startDate: '2026-07-13', endDate: '2026-07-13',
      });
      sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
      attendanceRepo.find.mockResolvedValue([]);
      dingRawRepo.find.mockResolvedValue([]);
      classStudentRepo.find.mockResolvedValue([]);
      await expect(service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21))
        .resolves.toMatchObject({ records: [] });
    } finally {
      jest.useRealTimers();
      process.env.TZ = originalTz;
    }
  });
});
