forked from wangziqi/gongxue-base
test: harden business boundary conditions
This commit is contained in:
31
apps/server/src/expenses/dto/expense.dto.spec.ts
Normal file
31
apps/server/src/expenses/dto/expense.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
107
apps/server/src/expenses/expenses.boundaries.spec.ts
Normal file
107
apps/server/src/expenses/expenses.boundaries.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user