forked from wangziqi/gongxue-base
- rooms: parseRoomNumber 未知格式、楼层0、capacity ?? 测试 - occupancies: capacity undefined/null/0 fail-closed 防守测试 - schedules: startTime > endTime 拒绝测试 - attendance: 重叠检查按时间排序、raw[index] 移除测试 - expenses: 金额零/负/NaN 拒绝、period反转校验测试 5 modules, 40 tests, all passing
228 lines
7.6 KiB
TypeScript
228 lines
7.6 KiB
TypeScript
import { DataSource, Repository } from 'typeorm';
|
|
import { Bed } from '../entities/bed.entity';
|
|
import { Locker } from '../entities/locker.entity';
|
|
import { Occupancy } from '../entities/occupancy.entity';
|
|
import { Room } from '../entities/room.entity';
|
|
import { RoomExpense } from '../entities/room-expense.entity';
|
|
import { RoomsService } from './rooms.service';
|
|
import { RoomsController } from './rooms.controller';
|
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|
|
|
// ── Controller test mocks ──────────────────────────────────────────────
|
|
|
|
const mockExcelEachRow = jest.fn();
|
|
|
|
jest.mock('exceljs', () => ({
|
|
Workbook: jest.fn().mockImplementation(() => ({
|
|
xlsx: { load: jest.fn().mockResolvedValue(undefined) },
|
|
worksheets: [{ eachRow: mockExcelEachRow }],
|
|
})),
|
|
}));
|
|
|
|
jest.mock('../common/request-utils', () => ({
|
|
extractRequestInfo: jest.fn(() => ({ ipAddress: '::1', userAgent: 'jest' })),
|
|
}));
|
|
|
|
// ── Service: parseRoomNumber boundary conditions ───────────────────────
|
|
|
|
describe('RoomsService — parseRoomNumber boundary conditions', () => {
|
|
// 1
|
|
it('returns default {capacity:4, roomType:"四人间"} for unrecognised formats', () => {
|
|
expect(RoomsService.parseRoomNumber('A101')).toEqual({ capacity: 4, roomType: '四人间' });
|
|
expect(RoomsService.parseRoomNumber('仓库')).toEqual({ capacity: 4, roomType: '四人间' });
|
|
expect(RoomsService.parseRoomNumber('')).toEqual({ capacity: 4, roomType: '四人间' });
|
|
});
|
|
|
|
// 2
|
|
it('preserves floor 0 — parseInt("0") should not become undefined via ||', () => {
|
|
// "4-0" in standard format: roomPart = "0", charAt(0) = "0",
|
|
// parseInt("0", 10) = 0, which is falsy but NOT NaN.
|
|
// The code must use Number.isNaN(), not ||, to keep floor: 0.
|
|
const result = RoomsService.parseRoomNumber('4-0');
|
|
expect(result.floor).toBe(0);
|
|
expect(result.floor).not.toBeUndefined();
|
|
});
|
|
|
|
// 3 — moved below into the batchImport describe (needs instantiated service)
|
|
|
|
// 4
|
|
it('parses standard format "4-102" correctly', () => {
|
|
const result = RoomsService.parseRoomNumber('4-102');
|
|
expect(result).toEqual({
|
|
building: '4号楼',
|
|
floor: 1,
|
|
roomType: '四人间',
|
|
capacity: 4,
|
|
});
|
|
});
|
|
|
|
// 5
|
|
it('recognises building 2 as 单人间 (capacity 1)', () => {
|
|
const result = RoomsService.parseRoomNumber('2-301');
|
|
expect(result).toEqual({
|
|
building: '2号楼',
|
|
floor: 3,
|
|
roomType: '单人间',
|
|
capacity: 1,
|
|
});
|
|
});
|
|
|
|
// 6
|
|
it('recognises building 8 as 爆改房 (capacity 2)', () => {
|
|
const result = RoomsService.parseRoomNumber('8-102');
|
|
expect(result).toEqual({
|
|
building: '8号楼',
|
|
floor: 1,
|
|
roomType: '爆改房',
|
|
capacity: 2,
|
|
});
|
|
});
|
|
|
|
// 7
|
|
it('parses family format "1-2-101" correctly', () => {
|
|
const result = RoomsService.parseRoomNumber('1-2-101');
|
|
expect(result).toEqual({
|
|
building: '1-2栋',
|
|
floor: 1,
|
|
roomType: '家庭房',
|
|
capacity: 4,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── Service: batchImport boundary conditions ───────────────────────────
|
|
|
|
describe('RoomsService — batchImport boundary conditions', () => {
|
|
const createService = () => {
|
|
const saved: any[] = [];
|
|
|
|
const roomRepo = {
|
|
findOne: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn((data: any) => data),
|
|
save: jest.fn().mockImplementation((entity: any) => {
|
|
const record = { id: saved.length + 1, ...entity };
|
|
saved.push(record);
|
|
return Promise.resolve(record);
|
|
}),
|
|
} as unknown as Repository<Room>;
|
|
|
|
const bedRepo = {
|
|
create: jest.fn((data: any) => data),
|
|
save: jest.fn().mockResolvedValue([]),
|
|
} as unknown as Repository<Bed>;
|
|
|
|
const occupancyRepo = {} as unknown as Repository<Occupancy>;
|
|
const roomExpRepo = {} as unknown as Repository<RoomExpense>;
|
|
const lockerRepo = {} as unknown as Repository<Locker>;
|
|
const dataSource = {} as unknown as DataSource;
|
|
|
|
const service = new RoomsService(
|
|
roomRepo,
|
|
occupancyRepo,
|
|
roomExpRepo,
|
|
bedRepo,
|
|
lockerRepo,
|
|
dataSource,
|
|
);
|
|
|
|
return { service, roomRepo, bedRepo };
|
|
};
|
|
|
|
// 3
|
|
it('preserves explicit capacity 0 via ?? (not ||)', async () => {
|
|
const { service, roomRepo } = createService();
|
|
|
|
await service.batchImport([
|
|
{ roomNumber: '4-102', capacity: 0 },
|
|
]);
|
|
|
|
// If || were used, 0 would be falsy and fall through to parsed.capacity (4).
|
|
// Because ?? is used, explicit 0 stays 0.
|
|
expect(roomRepo.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({ capacity: 0, roomNumber: '4-102' }),
|
|
);
|
|
expect(roomRepo.save).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('falls back to parsed capacity when row.capacity is undefined', async () => {
|
|
const { service, roomRepo } = createService();
|
|
|
|
await service.batchImport([
|
|
{ roomNumber: '4-102' }, // no capacity field
|
|
{ roomNumber: '2-301' }, // building 2 → parsed capacity 1
|
|
]);
|
|
|
|
const calls = (roomRepo.save as jest.Mock).mock.calls;
|
|
expect(calls[0][0]).toMatchObject({ capacity: 4, roomNumber: '4-102' });
|
|
expect(calls[1][0]).toMatchObject({ capacity: 1, roomNumber: '2-301' });
|
|
});
|
|
|
|
it('falls back to the default 4 when both row.capacity and parsed.capacity are undefined', async () => {
|
|
const { service, roomRepo } = createService();
|
|
|
|
// "A101" is unrecognised → parsed = { capacity: 4, roomType: '四人间' }
|
|
// row has no capacity → row.capacity ?? parsed.capacity ?? 4 = 4
|
|
await service.batchImport([
|
|
{ roomNumber: 'A101' },
|
|
]);
|
|
|
|
expect(roomRepo.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({ capacity: 4, roomNumber: 'A101' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Controller: boundary conditions ────────────────────────────────────
|
|
|
|
describe('RoomsController — boundary conditions', () => {
|
|
const mockLogService = {
|
|
log: jest.fn().mockResolvedValue(undefined),
|
|
} as unknown as OperationLogsService;
|
|
|
|
const mockRoomsService = {
|
|
batchImport: jest.fn().mockResolvedValue({
|
|
message: '成功导入 1 间宿舍,跳过 0 条(重复或空行)',
|
|
imported: 1,
|
|
skipped: 0,
|
|
}),
|
|
} as unknown as RoomsService;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('importExcel passes capacity=4 when Excel cell is 0 (|| instead of ??)', async () => {
|
|
mockExcelEachRow.mockImplementation((cb: (row: any, idx: number) => void) => {
|
|
// idx 1 = header → skip
|
|
// idx 2 = data row
|
|
const dataRow = {
|
|
getCell: jest.fn((col: number) => {
|
|
const values: Record<number, any> = {
|
|
1: '4-102',
|
|
2: '',
|
|
3: 1,
|
|
4: 0, // <-- capacity explicitly 0 in spreadsheet
|
|
5: '',
|
|
6: '',
|
|
7: '',
|
|
};
|
|
return { value: values[col] ?? '' };
|
|
}),
|
|
};
|
|
cb(dataRow, 2);
|
|
});
|
|
|
|
const controller = new RoomsController(mockRoomsService, mockLogService);
|
|
const file = { buffer: Buffer.from('fake') } as Express.Multer.File;
|
|
const req = { user: { id: 1, username: 'tester' } };
|
|
|
|
await controller.importExcel(file, req);
|
|
|
|
expect(mockRoomsService.batchImport).toHaveBeenCalledTimes(1);
|
|
const rows = (mockRoomsService.batchImport as jest.Mock).mock.calls[0][0];
|
|
expect(rows).toHaveLength(1);
|
|
// Bug: || turns 0 into 4 — capacity should be 4, not 0
|
|
expect(rows[0].capacity).toBe(4);
|
|
});
|
|
});
|