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,53 @@
import { BadRequestException } from '@nestjs/common';
import { DepositsService } from './deposits.service';
import { Deposit } from '../entities/deposit.entity';
function serviceWith(deposit?: Partial<Deposit>) {
const record = deposit ? ({ id: 1, studentId: 2, ...deposit } as Deposit) : null;
const repo = {
findOne: jest.fn(async (options: any) => options?.where?.studentId ? record : record),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
};
const installmentRepo = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
};
const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 2 }) };
return { service: new DepositsService(repo as any, installmentRepo as any, studentRepo as any), repo, installmentRepo };
}
describe('DepositsService boundaries', () => {
it('rejects an installment amount that rounds to zero', async () => {
const { service, installmentRepo } = serviceWith({ amount: 500, status: 'paid' });
await expect(service.addInstallment(1, 0.004, '2026-08-01')).rejects.toBeInstanceOf(BadRequestException);
expect(installmentRepo.save).not.toHaveBeenCalled();
});
it('rejects a repeated full refund', async () => {
const { service, repo } = serviceWith({ amount: 0, status: 'refunded' });
await expect(service.refund(1, { refundDate: '2026-07-14' })).rejects.toBeInstanceOf(BadRequestException);
expect(repo.save).not.toHaveBeenCalled();
});
it('rounds cumulative collections and clears stale refund audit fields', async () => {
const { service } = serviceWith({
amount: 10.01,
status: 'refunded',
refundDate: '2026-07-01',
refundAmount: 5,
refundedBy: 9,
refundedAt: new Date(),
});
const result = await service.create({ studentId: 2, amount: 0.02, paidDate: '2026-07-14' }, 7);
expect(result).toMatchObject({ amount: 10.03, status: 'paid', recordedBy: 7 });
expect(result.refundDate).toBeNull();
expect(result.refundAmount).toBeNull();
expect(result.refundedBy).toBeNull();
expect(result.refundedAt).toBeNull();
});
});

View File

@@ -7,6 +7,8 @@ import { DepositInstallment } from '../entities/deposit-installment.entity';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@Injectable()
export class DepositsService {
@@ -46,14 +48,22 @@ export class DepositsService {
async create(dto: CreateDepositDto, userId?: number) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
if (Number(dto.amount) <= 0) throw new BadRequestException('收取金额必须大于0');
const amount = money(dto.amount);
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
throw new BadRequestException('收取金额最多保留两位小数');
}
if (amount <= 0) throw new BadRequestException('收取金额必须大于0');
const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
if (existing) {
existing.amount = Number((Number(existing.amount || 0) + Number(dto.amount)).toFixed(2));
existing.amount = money(Number(existing.amount || 0) + amount);
existing.paidDate = dto.paidDate;
existing.status = 'paid';
existing.recordedBy = userId ?? null;
existing.refundDate = null as unknown as string;
existing.refundAmount = null as unknown as number;
existing.refundedBy = null;
existing.refundedAt = null;
if (dto.notes) existing.notes = dto.notes;
return this.repo.save(existing);
}
@@ -61,7 +71,7 @@ export class DepositsService {
return this.repo.save(
this.repo.create({
studentId: dto.studentId,
amount: dto.amount,
amount,
paidDate: dto.paidDate,
notes: dto.notes,
status: 'paid',
@@ -71,12 +81,17 @@ export class DepositsService {
}
async addInstallment(depositId: number, amount: number, dueDate: string) {
const normalizedAmount = money(amount);
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException('分期金额最多保留两位小数');
}
if (normalizedAmount <= 0) throw new BadRequestException('分期金额必须大于0');
const deposit = await this.repo.findOne({ where: { id: depositId } });
if (!deposit) throw new NotFoundException('押金记录不存在');
const installment = this.installmentRepo.create({
depositId,
amount,
amount: normalizedAmount,
dueDate,
status: 'pending',
});
@@ -106,7 +121,7 @@ export class DepositsService {
throw new BadRequestException('该学生当前没有可退押金');
}
const refundAmount = Number(deposit.amount);
const refundAmount = money(deposit.amount);
deposit.refundDate = dto.refundDate;
deposit.refundAmount = refundAmount;