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,31 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { BatchRoomExpenseDto } from './expense.dto';
describe('BatchRoomExpenseDto boundaries', () => {
it.each([
{ expenses: [] },
{ expenses: [{ roomId: 1, expenseType: 'water', amount: 0 }] },
{ expenses: [{ roomId: 1, expenseType: 'water', amount: -1 }] },
{ expenses: [{ roomId: 1, expenseType: 'water', amount: 1.001 }] },
{ periodStart: '2026-02-31' },
])('rejects invalid batch payload %#', async (override) => {
const dto = plainToInstance(BatchRoomExpenseDto, {
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
expenses: [{ roomId: 1, expenseType: 'water', amount: 10 }],
...override,
});
await expect(validate(dto)).resolves.not.toHaveLength(0);
});
it('accepts a valid batch payload', async () => {
const dto = plainToInstance(BatchRoomExpenseDto, {
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
expenses: [{ roomId: 1, expenseType: 'water', amount: 10.25 }],
});
await expect(validate(dto)).resolves.toHaveLength(0);
});
});

View File

@@ -1,4 +1,4 @@
import { IsDateString, IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator';
import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsISO8601, IsString, IsNumber, IsOptional, Matches, Min, ValidateNested } from 'class-validator';
import { PartialType } from '@nestjs/mapped-types';
import { Type } from 'class-transformer';
@@ -13,9 +13,13 @@ export class CreateRoomExpenseDto {
@Min(0.01)
amount: number;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString()
periodStart: string;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString()
periodEnd: string;
@@ -39,6 +43,8 @@ export class CreatePersonalExpenseDto {
@Min(0.01)
amount: number;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString()
expenseDate: string;
@@ -74,14 +80,38 @@ export class QueryPersonalExpenseDto {
studentId?: number;
}
export class BatchRoomExpenseDto {
export class BatchRoomExpenseItemDto {
@IsInt()
roomId: number;
@IsString()
expenseType: string;
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number;
@IsOptional()
@IsString()
description?: string;
}
export class BatchRoomExpenseDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString()
periodStart: string;
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString()
periodEnd: string;
expenses: { roomId: number; expenseType: string; amount: number; description?: string }[];
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => BatchRoomExpenseItemDto)
expenses: BatchRoomExpenseItemDto[];
}

View File

@@ -0,0 +1,107 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExpensesService } from './expenses.service';
import { PersonalExpense } from '../entities/personal-expense.entity';
const qb = (affected = 1) => ({
delete: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected }),
});
function createService(options?: {
roomFind?: any[];
personalFind?: any[];
roomExpense?: any;
personalExpense?: any;
}) {
const roomExpRepo = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn().mockResolvedValue(options?.roomFind ?? []),
findOne: jest.fn().mockResolvedValue(options?.roomExpense ?? null),
createQueryBuilder: jest.fn(() => qb()),
};
const personalExpRepo = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn().mockResolvedValue(options?.personalFind ?? []),
findOne: jest.fn().mockResolvedValue(options?.personalExpense ?? null),
createQueryBuilder: jest.fn(() => qb()),
delete: jest.fn(),
};
const roomRepo = {
find: jest.fn().mockImplementation(async () => options?.roomFind ?? []),
findOne: jest.fn().mockResolvedValue({ id: 1 }),
};
const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }) };
return {
service: new ExpensesService(roomExpRepo as any, personalExpRepo as any, roomRepo as any, studentRepo as any, {} as any),
roomExpRepo,
personalExpRepo,
roomRepo,
};
}
describe('ExpensesService boundaries', () => {
it('rejects an empty room-expense batch', async () => {
const { service, roomExpRepo } = createService();
await expect(service.batchCreateRoomExpenses({
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
expenses: [],
})).rejects.toBeInstanceOf(BadRequestException);
expect(roomExpRepo.save).not.toHaveBeenCalled();
});
it('rejects a batch when any room does not exist', async () => {
const { service, roomExpRepo } = createService({ roomFind: [{ id: 1 }] });
await expect(service.batchCreateRoomExpenses({
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
expenses: [
{ roomId: 1, expenseType: 'water', amount: 10 },
{ roomId: 2, expenseType: 'water', amount: 20 },
],
})).rejects.toBeInstanceOf(NotFoundException);
expect(roomExpRepo.save).not.toHaveBeenCalled();
});
it('rejects a reversed room-expense period', async () => {
const { service } = createService();
await expect(service.createRoomExpense({
roomId: 1,
expenseType: 'water',
amount: 10,
periodStart: '2026-08-01',
periodEnd: '2026-07-31',
})).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a batch delete when only part of the ids exist', async () => {
const { service, roomExpRepo } = createService({ roomFind: [{ id: 1 }] });
await expect(service.batchDeleteRoomExpenses([1, 2])).rejects.toBeInstanceOf(NotFoundException);
expect(roomExpRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('does not edit or delete a personal expense already linked to a bill', async () => {
const linked = { id: 1, studentId: 1, amount: 20, billId: 9 } as PersonalExpense;
const { service, personalExpRepo } = createService({ personalExpense: linked });
await expect(service.updatePersonalExpense(1, { amount: 30 })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.deletePersonalExpense(1)).rejects.toBeInstanceOf(BadRequestException);
expect(personalExpRepo.save).not.toHaveBeenCalled();
expect(personalExpRepo.delete).not.toHaveBeenCalled();
});
it('rejects a personal-expense batch delete containing billed records', async () => {
const { service, personalExpRepo } = createService({
personalFind: [
{ id: 1, billId: null },
{ id: 2, billId: 9 },
],
});
await expect(service.batchDeletePersonalExpenses([1, 2])).rejects.toBeInstanceOf(BadRequestException);
expect(personalExpRepo.createQueryBuilder).not.toHaveBeenCalled();
});
});

View File

@@ -42,6 +42,8 @@ export class ExpensesService {
// 宿舍费用
async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) {
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
this.assertPositiveAmount(dto.amount);
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId });
@@ -49,6 +51,12 @@ export class ExpensesService {
}
async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) {
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
if (!dto.expenses?.length) throw new BadRequestException('请至少填写一条费用');
dto.expenses.forEach((expense) => this.assertPositiveAmount(expense.amount));
const roomIds = [...new Set(dto.expenses.map((expense) => expense.roomId))];
const existingRooms = await this.roomRepo.find({ where: { id: In(roomIds) }, select: ['id'] });
if (existingRooms.length !== roomIds.length) throw new NotFoundException('部分宿舍不存在');
const entities = dto.expenses.map((e) => {
const entity = this.roomExpRepo.create({
roomId: e.roomId,
@@ -83,11 +91,14 @@ export class ExpensesService {
}
async batchDeleteRoomExpenses(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const result = await this.roomExpRepo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids })
.where('id IN (:...ids)', { ids: uniqueIds })
.execute();
return { message: '批量删除成功', deleted: result.affected || 0 };
}
@@ -95,12 +106,40 @@ export class ExpensesService {
async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
const e = await this.roomExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
const periodStart = dto.periodStart ?? e.periodStart;
const periodEnd = dto.periodEnd ?? e.periodEnd;
this.assertValidPeriod(periodStart, periodEnd);
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
if (dto.roomId !== undefined && dto.roomId !== e.roomId) {
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
}
Object.assign(e, dto);
return this.roomExpRepo.save(e);
}
private assertPositiveAmount(amount: number) {
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException('费用金额最多保留两位小数');
}
if (amount <= 0) throw new BadRequestException('费用金额必须大于0');
}
private assertValidPeriod(periodStart: string, periodEnd: string) {
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
throw new BadRequestException('账期无效,结束日期不能早于开始日期');
}
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) {
if (dto.periodEnd < dto.periodStart) throw new BadRequestException('账期结束日期不能早于开始日期');
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const expense = await this.personalExpRepo.save(
@@ -125,6 +164,7 @@ export class ExpensesService {
// 个人附加费
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId });
@@ -144,16 +184,23 @@ export class ExpensesService {
async deletePersonalExpense(id: number) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能删除,请先取消账单');
await this.personalExpRepo.delete(id);
return { message: '删除成功' };
}
async batchDeletePersonalExpenses(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
if (existing.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const result = await this.personalExpRepo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids })
.where('id IN (:...ids)', { ids: uniqueIds })
.execute();
return { message: '批量删除成功', deleted: result.affected || 0 };
}
@@ -161,6 +208,12 @@ export class ExpensesService {
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单');
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
if (dto.studentId !== undefined && dto.studentId !== e.studentId) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
}
Object.assign(e, dto);
return this.personalExpRepo.save(e);
}