forked from wangziqi/gongxue-base
test: harden business boundary conditions
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
SubjectName,
|
||||
} from '../authorization';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dto/dashboard-query.dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
@@ -43,28 +44,18 @@ export class DashboardController {
|
||||
}
|
||||
|
||||
@Get('gantt')
|
||||
getGanttData(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
@Query('building') building?: string,
|
||||
) {
|
||||
return this.service.getGanttData({ periodStart, periodEnd, building });
|
||||
getGanttData(@Query() query: DashboardGanttQueryDto) {
|
||||
return this.service.getGanttData(query);
|
||||
}
|
||||
|
||||
@Get('expense-stats')
|
||||
getExpenseStats(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
) {
|
||||
return this.service.getExpenseStats(periodStart, periodEnd);
|
||||
getExpenseStats(@Query() query: DashboardPeriodQueryDto) {
|
||||
return this.service.getExpenseStats(query.periodStart, query.periodEnd);
|
||||
}
|
||||
|
||||
@Get('room-ranking')
|
||||
getRoomExpenseRanking(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
) {
|
||||
return this.service.getRoomExpenseRanking(periodStart, periodEnd);
|
||||
getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto) {
|
||||
return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd);
|
||||
}
|
||||
|
||||
@Get('class-attendance-ranking')
|
||||
|
||||
@@ -42,3 +42,45 @@ describe('DashboardService — teacher class scope', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService — boundary conditions', () => {
|
||||
it('uses a deny-all predicate instead of an empty SQL IN list', async () => {
|
||||
const qb = createQb();
|
||||
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const service = new DashboardService(
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never, attendanceRepo as never, {} as never, {} as never, {} as never,
|
||||
{} as never, {} as never,
|
||||
);
|
||||
|
||||
await (service as unknown as {
|
||||
getAttendanceTrend: (today: string, classIds: number[]) => Promise<unknown>;
|
||||
}).getAttendanceTrend('2026-07-14', []);
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('1 = 0');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['getGanttData', [{ periodStart: '2026-08-01', periodEnd: '2026-07-31' }]],
|
||||
['getExpenseStats', ['2026-08-01', '2026-07-31']],
|
||||
['getRoomExpenseRanking', ['2026-08-01', '2026-07-31']],
|
||||
] as const)('rejects a reversed period in %s', async (method, args) => {
|
||||
const service = new DashboardService(
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect((service[method] as (...values: never[]) => Promise<unknown>)(...(args as never[])))
|
||||
.rejects.toThrow('结束日期不能早于开始日期');
|
||||
});
|
||||
|
||||
it('uses the China calendar date when the server timezone is behind China', () => {
|
||||
const service = new DashboardService(
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never,
|
||||
);
|
||||
expect((service as unknown as { getChinaDate: (date: Date) => string })
|
||||
.getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm';
|
||||
import { Room } from '../entities/room.entity';
|
||||
@@ -40,8 +40,7 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
async getStats(accessibleClassIds?: number[]) {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const todayStr = this.getChinaDate(new Date());
|
||||
const currentMonth = todayStr.slice(0, 7); // YYYY-MM
|
||||
|
||||
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
|
||||
@@ -180,6 +179,10 @@ export class DashboardService {
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) {
|
||||
qb.andWhere('1 = 0');
|
||||
return;
|
||||
}
|
||||
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
|
||||
}
|
||||
}
|
||||
@@ -260,6 +263,7 @@ export class DashboardService {
|
||||
|
||||
// 甘特图数据:每个宿舍的入住时间线
|
||||
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
|
||||
this.assertPeriodRange(query?.periodStart, query?.periodEnd);
|
||||
const qb = this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.student', 'student')
|
||||
@@ -302,6 +306,7 @@ export class DashboardService {
|
||||
}
|
||||
// 费用统计
|
||||
async getExpenseStats(periodStart?: string, periodEnd?: string) {
|
||||
this.assertPeriodRange(periodStart, periodEnd);
|
||||
const qb = this.expRepo
|
||||
.createQueryBuilder('e')
|
||||
.select('e.expenseType', 'type')
|
||||
@@ -314,6 +319,7 @@ export class DashboardService {
|
||||
|
||||
// 各宿舍费用排行
|
||||
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
|
||||
this.assertPeriodRange(periodStart, periodEnd);
|
||||
const qb = this.expRepo
|
||||
.createQueryBuilder('e')
|
||||
.leftJoin('e.room', 'room')
|
||||
@@ -368,7 +374,7 @@ export class DashboardService {
|
||||
where: { status: 'available' as const },
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = this.getChinaDate(new Date());
|
||||
const schedQb = this.scheduleRepo
|
||||
.createQueryBuilder('s')
|
||||
.select('s.classroomId', 'classroomId')
|
||||
@@ -400,12 +406,31 @@ export class DashboardService {
|
||||
}));
|
||||
}
|
||||
|
||||
private assertPeriodRange(periodStart?: string, periodEnd?: string) {
|
||||
if (periodStart && periodEnd && periodStart > periodEnd) {
|
||||
throw new BadRequestException('结束日期不能早于开始日期');
|
||||
}
|
||||
}
|
||||
|
||||
private getChinaDate(date: Date): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(date);
|
||||
const values = Object.fromEntries(
|
||||
parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
|
||||
);
|
||||
return `${values.year}-${values.month}-${values.day}`;
|
||||
}
|
||||
|
||||
async getClassroomUtilizationStats() {
|
||||
const totalClassrooms = await this.classroomRepo.count({
|
||||
where: { status: 'available' as const },
|
||||
});
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = this.getChinaDate(new Date());
|
||||
|
||||
// Count classrooms with active schedules today
|
||||
const schedQb = this.scheduleRepo
|
||||
|
||||
28
apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts
Normal file
28
apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto';
|
||||
|
||||
describe('dashboard query boundaries', () => {
|
||||
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
|
||||
'rejects invalid or non-date-only value %s',
|
||||
async (periodStart) => {
|
||||
const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart });
|
||||
expect((await validate(dto)).some((error) => error.property === 'periodStart')).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts a valid date range and bounded building name', async () => {
|
||||
const dto = plainToInstance(DashboardGanttQueryDto, {
|
||||
periodStart: '2026-07-01',
|
||||
periodEnd: '2026-07-31',
|
||||
building: 'A座',
|
||||
});
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an excessively long building name', async () => {
|
||||
const dto = plainToInstance(DashboardGanttQueryDto, { building: 'A'.repeat(51) });
|
||||
expect((await validate(dto)).some((error) => error.property === 'building')).toBe(true);
|
||||
});
|
||||
});
|
||||
20
apps/server/src/dashboard/dto/dashboard-query.dto.ts
Normal file
20
apps/server/src/dashboard/dto/dashboard-query.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class DashboardPeriodQueryDto {
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
periodStart?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
periodEnd?: string;
|
||||
}
|
||||
|
||||
export class DashboardGanttQueryDto extends DashboardPeriodQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
building?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user