forked from wangziqi/gongxue-base
test: harden business boundary conditions
This commit is contained in:
35
apps/server/src/sync/dto/schedule-sync.dto.spec.ts
Normal file
35
apps/server/src/sync/dto/schedule-sync.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
apps/server/src/sync/dto/schedule-sync.dto.ts
Normal file
21
apps/server/src/sync/dto/schedule-sync.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user