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

@@ -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')

View File

@@ -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');
});
});

View File

@@ -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

View 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);
});
});

View 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;
}