test: harden business boundary conditions

This commit is contained in:
2026-07-15 00:03:55 +08:00
parent 17a5046ea0
commit b1f35f9d1a
65 changed files with 2311 additions and 293 deletions

View File

@@ -0,0 +1,35 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ScheduleSyncQueryDto } from './schedule-sync.dto';
describe('ScheduleSyncQueryDto', () => {
it('transforms valid query strings', async () => {
const dto = plainToInstance(ScheduleSyncQueryDto, {
dateFrom: '2026-07-13',
days: '7',
attendanceMachineOnly: 'true',
});
expect(await validate(dto)).toEqual([]);
expect(dto).toMatchObject({ days: 7, attendanceMachineOnly: true });
});
it.each(['0', '-1', '91', '7.5', 'abc'])('rejects invalid sync days %s', async (days) => {
const dto = plainToInstance(ScheduleSyncQueryDto, { days });
expect((await validate(dto)).some((error) => error.property === 'days')).toBe(true);
});
it.each(['not-a-date', '2026-02-31', '2026-07-13T00:00:00Z'])(
'rejects invalid or non-date-only start date %s',
async (dateFrom) => {
const dto = plainToInstance(ScheduleSyncQueryDto, { dateFrom });
expect((await validate(dto)).some((error) => error.property === 'dateFrom')).toBe(true);
},
);
it('treats non-true boolean strings as false', async () => {
const dto = plainToInstance(ScheduleSyncQueryDto, { attendanceMachineOnly: 'false' });
expect(await validate(dto)).toEqual([]);
expect(dto.attendanceMachineOnly).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsISO8601, IsInt, IsOptional, Matches, Max, Min } from 'class-validator';
export class ScheduleSyncQueryDto {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
dateFrom?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(90)
days: number = 30;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
attendanceMachineOnly: boolean = false;
}

View File

@@ -549,3 +549,63 @@ describe('ScheduleSyncService — multiple lessons per student per day', () => {
});
});
describe('ScheduleSyncService — date and overnight boundaries', () => {
const createService = (schedule: ClassSchedule) => {
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
const upsertShift = jest.fn().mockResolvedValue(501);
const service = new ScheduleSyncService(
{ find: jest.fn().mockResolvedValue([schedule]) } as never,
{ find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]) } as never,
{ find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]) } as never,
{ find: jest.fn().mockResolvedValue([{ id: 10, name: '边界班' }]) } as never,
{
queryShifts: jest.fn().mockResolvedValue([]), upsertShift,
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 88, group_name: '排课_边界班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn(), scheduleUsers,
} as never,
);
return { service, scheduleUsers, upsertShift };
};
it('syncs exactly the requested number of calendar days', async () => {
const { service, scheduleUsers } = createService({
id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00',
startDate: '2026-07-13', endDate: '2026-07-20', status: 'active',
} as ClassSchedule);
await service.syncAll('2026-07-13', 7);
expect(scheduleUsers.mock.calls.flatMap((call) => call[1])).toHaveLength(1);
});
it('marks an overnight lesson off-duty time as next-day', async () => {
const { service, upsertShift } = createService({
id: 1, classId: 10, weekDay: 1, startTime: '22:00', endTime: '01:00',
startDate: '2026-07-13', endDate: '2026-07-13', status: 'active',
} as ClassSchedule);
await service.syncAll('2026-07-13', 1);
expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({
sections: [expect.objectContaining({ times: expect.arrayContaining([
expect.objectContaining({ check_type: 'OnDuty', across: 0 }),
expect.objectContaining({ check_type: 'OffDuty', across: 1 }),
]) })],
}));
});
it('does not shift the requested date when the server timezone is behind China', async () => {
const originalTz = process.env.TZ;
process.env.TZ = 'America/Los_Angeles';
try {
const { service, scheduleUsers } = createService({
id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00',
startDate: '2026-07-13', endDate: '2026-07-13', status: 'active',
} as ClassSchedule);
await service.syncAll('2026-07-13', 1);
expect(scheduleUsers).toHaveBeenCalledTimes(1);
} finally {
process.env.TZ = originalTz;
}
});
});

View File

@@ -94,7 +94,8 @@ export class ScheduleSyncService {
attendanceMachineOnly = false,
): Promise<ScheduleSyncResult> {
const startDate = dateFrom || new Date().toISOString().slice(0, 10);
const endDate = this.addDays(startDate, days);
const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30;
const endDate = this.addDays(startDate, normalizedDays - 1);
const empty: ScheduleSyncResult = {
scheduleCount: 0,
@@ -170,7 +171,7 @@ export class ScheduleSyncService {
},
{
check_type: 'OffDuty' as const,
across: 0,
across: this.toMinutes(period.endTime) <= this.toMinutes(period.startTime) ? 1 : 0,
check_time: `1970-01-01 ${period.endTime}:00`,
free_check: false,
},
@@ -377,12 +378,12 @@ export class ScheduleSyncService {
syncTo: string,
): DailySchedulePlan[] {
const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
const fromDate = new Date(syncFrom);
const toDate = new Date(syncTo);
const fromDate = new Date(`${syncFrom}T00:00:00.000Z`);
const toDate = new Date(`${syncTo}T00:00:00.000Z`);
for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) {
for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {
const dateStr = date.toISOString().slice(0, 10);
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay();
for (const schedule of schedules) {
if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
@@ -450,18 +451,21 @@ export class ScheduleSyncService {
return items;
}
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
private minutesBetween(startTime: string, endTime: string): number {
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);
const start = startHour * 60 + startMinute;
let end = endHour * 60 + endMinute;
const start = this.toMinutes(startTime);
let end = this.toMinutes(endTime);
if (end <= start) end += 24 * 60;
return end - start;
}
private addDays(dateStr: string, days: number): string {
const d = new Date(dateStr);
d.setDate(d.getDate() + days);
const d = new Date(`${dateStr}T00:00:00.000Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}

View File

@@ -1,4 +1,5 @@
import { SyncController } from './sync.controller';
import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto';
describe('SyncController — schedule sync options', () => {
it('forwards the attendance-machine-only option', async () => {
@@ -7,11 +8,11 @@ describe('SyncController — schedule sync options', () => {
};
const controller = new SyncController(syncService as never);
await (controller.syncSchedule as unknown as (
dateFrom?: string,
days?: string,
attendanceMachineOnly?: string,
) => Promise<unknown>)('2026-07-10', '30', 'true');
await controller.syncSchedule(Object.assign(new ScheduleSyncQueryDto(), {
dateFrom: '2026-07-10',
days: 30,
attendanceMachineOnly: true,
}));
expect(syncService.syncScheduleToDingTalk).toHaveBeenCalledWith(
'2026-07-10',

View File

@@ -3,6 +3,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { SyncService } from './sync.service';
import type { SyncPlatform } from '../entities/sync-log.entity';
import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto';
@UseGuards(JwtAuthGuard)
@Controller('sync')
@@ -79,15 +80,11 @@ export class SyncController {
/** 触发排班同步到钉钉考勤排班 */
@Post('schedule/sync')
@RequirePermission('sync:trigger')
async syncSchedule(
@Query('dateFrom') dateFrom?: string,
@Query('days') days?: string,
@Query('attendanceMachineOnly') attendanceMachineOnly?: string,
) {
async syncSchedule(@Query() query: ScheduleSyncQueryDto) {
const result = await this.syncService.syncScheduleToDingTalk(
dateFrom,
days ? parseInt(days, 10) : 30,
attendanceMachineOnly === 'true',
query.dateFrom,
query.days,
query.attendanceMachineOnly,
);
return { success: true, data: result };
}