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,74 @@
import { Type } from 'class-transformer';
import {
IsISO8601,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
export class QueryOperationLogsDto {
@IsOptional()
@IsString()
@MaxLength(50)
module?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
userId?: number;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 50;
}
export class CreateAuditLogDto {
@IsString()
@IsNotEmpty()
@MaxLength(50)
module: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
action: string;
@IsOptional()
@IsInt()
targetId?: number;
@IsOptional()
@IsString()
@MaxLength(50)
targetType?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
detail?: string;
}

View File

@@ -3,6 +3,7 @@ import { OperationLogsService } from './operation-logs.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { extractRequestInfo } from '../common/request-utils';
import { CreateAuditLogDto, QueryOperationLogsDto } from './dto/operation-log.dto';
@UseGuards(JwtAuthGuard)
@Controller('operation-logs')
@@ -11,29 +12,15 @@ export class OperationLogsController {
@Get()
@RequirePermission('log:view')
findAll(
@Query('module') module?: string,
@Query('userId') userId?: string,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
module,
userId: userId ? +userId : undefined,
startDate,
endDate,
page: page ? +page : 1,
pageSize: pageSize ? +pageSize : 50,
});
findAll(@Query() query: QueryOperationLogsDto) {
return this.service.findAll(query);
}
@Post('audit')
@RequirePermission('log:create')
async createAuditLog(
@Body() body: { module: string; action: string; targetId?: number; targetType?: string; detail?: string },
@Body() body: CreateAuditLogDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);

View File

@@ -0,0 +1,52 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { QueryOperationLogsDto } from './dto/operation-log.dto';
import { OperationLogsService } from './operation-logs.service';
const createQb = () => ({
orderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
});
describe('operation log query boundaries', () => {
it.each(['0', '-1', '1.5', 'abc'])('rejects invalid page %s', async (page) => {
const dto = plainToInstance(QueryOperationLogsDto, { page });
expect((await validate(dto)).some((error) => error.property === 'page')).toBe(true);
});
it.each(['0', '201', '1.5', 'abc'])('rejects invalid page size %s', async (pageSize) => {
const dto = plainToInstance(QueryOperationLogsDto, { pageSize });
expect((await validate(dto)).some((error) => error.property === 'pageSize')).toBe(true);
});
it.each(['2026-02-31', '2026-07-13T00:00:00Z'])(
'rejects invalid or non-date-only value %s',
async (startDate) => {
const dto = plainToInstance(QueryOperationLogsDto, { startDate });
expect((await validate(dto)).some((error) => error.property === 'startDate')).toBe(true);
},
);
it('transforms valid pagination and applies its database window', async () => {
const dto = plainToInstance(QueryOperationLogsDto, { page: '2', pageSize: '20' });
expect(await validate(dto)).toEqual([]);
const qb = createQb();
const service = new OperationLogsService({ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never);
await service.findAll(dto);
expect(qb.skip).toHaveBeenCalledWith(20);
expect(qb.take).toHaveBeenCalledWith(20);
});
it('rejects a reversed period before opening a query', async () => {
const repo = { createQueryBuilder: jest.fn() };
const service = new OperationLogsService(repo as never);
await expect(service.findAll({ startDate: '2026-08-01', endDate: '2026-07-31' }))
.rejects.toThrow('结束日期不能早于开始日期');
expect(repo.createQueryBuilder).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OperationLog } from '../entities/operation-log.entity';
@@ -31,6 +31,9 @@ export class OperationLogsService {
page?: number;
pageSize?: number;
}) {
if (query?.startDate && query?.endDate && query.startDate > query.endDate) {
throw new BadRequestException('结束日期不能早于开始日期');
}
const qb = this.repo.createQueryBuilder('log').orderBy('log.createdAt', 'DESC');
if (query?.module) qb.andWhere('log.module = :module', { module: query.module });
if (query?.userId) qb.andWhere('log.userId = :userId', { userId: query.userId });