feat: 支持单元测试

This commit is contained in:
richarjiang
2026-04-08 16:02:19 +08:00
parent 2d0fee8a9a
commit df05b7280c
18 changed files with 2462 additions and 17 deletions

View File

@@ -0,0 +1,119 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { ShareController } from './share.controller';
import { ShareService } from './share.service';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
describe('ShareController', () => {
let controller: ShareController;
const mockUser: JwtPayload = {
sub: 'user-uuid-1',
openid: 'wx-openid-123',
};
const mockShareService = {
createShare: jest.fn(),
joinShare: jest.fn(),
reportLevelProgress: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ShareController],
providers: [
{ provide: ShareService, useValue: mockShareService },
{ provide: JwtService, useValue: { verifyAsync: jest.fn() } },
],
}).compile();
controller = module.get<ShareController>(ShareController);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('createShare', () => {
it('should return success response with share details', async () => {
const shareResponse = {
shareCode: 'ABCD1234',
title: '我的挑战',
levelCount: 6,
};
mockShareService.createShare.mockResolvedValue(shareResponse);
const dto = {
title: '我的挑战',
levelIds: ['l1', 'l2', 'l3', 'l4', 'l5', 'l6'],
};
const result = await controller.createShare(mockUser, dto);
expect(result).toBeInstanceOf(ApiResponseDto);
expect(result.success).toBe(true);
expect(result.data).toEqual(shareResponse);
expect(mockShareService.createShare).toHaveBeenCalledWith(
'user-uuid-1',
dto,
);
});
});
describe('joinShare', () => {
it('should return success response with share levels', async () => {
const joinResponse = {
shareCode: 'ABCD1234',
title: '我的挑战',
levels: [
{
id: 'l1',
level: 1,
imageUrl: 'https://example.com/1.jpg',
answer: '答案',
hint1: null,
hint2: null,
hint3: null,
sortOrder: 0,
},
],
};
mockShareService.joinShare.mockResolvedValue(joinResponse);
const result = await controller.joinShare(mockUser, 'ABCD1234');
expect(result.success).toBe(true);
expect(result.data).toEqual(joinResponse);
expect(mockShareService.joinShare).toHaveBeenCalledWith(
'user-uuid-1',
'ABCD1234',
);
});
});
describe('reportLevelProgress', () => {
it('should return success response with progress result', async () => {
const progressResponse = {
passed: true,
timeLimit: 60,
withinTimeLimit: true,
};
mockShareService.reportLevelProgress.mockResolvedValue(progressResponse);
const dto = {
shareCode: 'ABCD1234',
levelId: 'level-1',
passed: true,
timeSpent: 30,
};
const result = await controller.reportLevelProgress(mockUser, dto);
expect(result.success).toBe(true);
expect(result.data).toEqual(progressResponse);
expect(mockShareService.reportLevelProgress).toHaveBeenCalledWith(
'user-uuid-1',
dto,
);
});
});
});

View File

@@ -0,0 +1,409 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { ShareService } from './share.service';
import { ShareConfigRepository } from './repositories/share-config.repository';
import { ShareParticipantRepository } from './repositories/share-participant.repository';
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
import { LevelRepository } from '../wechat-game/repositories/level.repository';
import { Level } from '../wechat-game/entities/level.entity';
import { ShareConfig } from './entities/share-config.entity';
import { ShareParticipant } from './entities/share-participant.entity';
import { ShareLevelProgress } from './entities/share-level-progress.entity';
// Mock nanoid to return predictable values
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'ABCD1234'),
}));
describe('ShareService', () => {
let service: ShareService;
const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({
id: `level-${i + 1}`,
imageUrl: `https://example.com/meme${i + 1}.jpg`,
answer: `答案${i + 1}`,
hint1: `提示${i + 1}`,
hint2: null,
hint3: null,
sortOrder: i,
timeLimit: i === 0 ? 60 : null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
}));
const mockShareConfig: ShareConfig = {
id: 'share-uuid-1',
shareCode: 'ABCD1234',
title: '我的挑战',
sharerId: 'user-uuid-1',
levelIds: mockLevels.map((l) => l.id),
sharer: {} as any,
participants: [],
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockParticipant: ShareParticipant = {
id: 'participant-uuid-1',
shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-2',
shareConfig: {} as any,
participant: {} as any,
createdAt: new Date('2026-01-01'),
};
const mockShareConfigRepository = {
create: jest.fn(),
findByShareCode: jest.fn(),
};
const mockShareParticipantRepository = {
addParticipant: jest.fn(),
countByShareConfigId: jest.fn(),
findByShareConfigAndParticipant: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockShareLevelProgressRepository = {
findByParticipantAndLevel: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockLevelRepository = {
findByIds: jest.fn(),
findById: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ShareService,
{ provide: ShareConfigRepository, useValue: mockShareConfigRepository },
{
provide: ShareParticipantRepository,
useValue: mockShareParticipantRepository,
},
{
provide: ShareLevelProgressRepository,
useValue: mockShareLevelProgressRepository,
},
{ provide: LevelRepository, useValue: mockLevelRepository },
],
}).compile();
service = module.get<ShareService>(ShareService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('createShare', () => {
const createDto = {
title: '我的挑战',
levelIds: ['level-1', 'level-2', 'level-3', 'level-4', 'level-5', 'level-6'],
};
it('should create a share with valid 6 unique levels', async () => {
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
mockShareConfigRepository.create.mockResolvedValue(mockShareConfig);
const result = await service.createShare('user-uuid-1', createDto);
expect(result.shareCode).toBe('ABCD1234');
expect(result.title).toBe('我的挑战');
expect(result.levelCount).toBe(6);
});
it('should throw BadRequestException when level IDs have duplicates', async () => {
const duplicateDto = {
title: '测试',
levelIds: ['level-1', 'level-1', 'level-2', 'level-3', 'level-4', 'level-5'],
};
await expect(
service.createShare('user-uuid-1', duplicateDto),
).rejects.toThrow(BadRequestException);
});
it('should throw NotFoundException when some levels do not exist', async () => {
mockLevelRepository.findByIds.mockResolvedValue(mockLevels.slice(0, 4));
await expect(
service.createShare('user-uuid-1', createDto),
).rejects.toThrow(NotFoundException);
});
it('should throw BadRequestException when share code generation fails', async () => {
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
// All 3 attempts find existing codes
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
await expect(
service.createShare('user-uuid-1', createDto),
).rejects.toThrow(BadRequestException);
});
});
describe('joinShare', () => {
it('should return share details with ordered levels for a participant', async () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockShareParticipantRepository.addParticipant.mockResolvedValue(undefined);
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
const result = await service.joinShare('user-uuid-2', 'ABCD1234');
expect(result.shareCode).toBe('ABCD1234');
expect(result.title).toBe('我的挑战');
expect(result.levels).toHaveLength(6);
expect(result.levels[0].level).toBe(1);
expect(result.levels[0].id).toBe('level-1');
expect(mockShareParticipantRepository.addParticipant).toHaveBeenCalledWith(
'share-uuid-1',
'user-uuid-2',
);
});
it('should not add participant when user is the sharer', async () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
const result = await service.joinShare('user-uuid-1', 'ABCD1234');
expect(result.shareCode).toBe('ABCD1234');
expect(
mockShareParticipantRepository.addParticipant,
).not.toHaveBeenCalled();
});
it('should throw NotFoundException when share code not found', async () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
await expect(
service.joinShare('user-uuid-2', 'INVALID'),
).rejects.toThrow(NotFoundException);
});
});
describe('reportLevelProgress', () => {
const reportDto = {
shareCode: 'ABCD1234',
levelId: 'level-1',
passed: true,
timeSpent: 30,
};
it('should create new progress record for first attempt', async () => {
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 };
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
mockParticipant,
);
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
null,
);
const newProgress: Partial<ShareLevelProgress> = {
participantId: 'participant-uuid-1',
levelId: 'level-1',
passed: true,
timeSpent: 30,
};
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
const result = await service.reportLevelProgress(
'user-uuid-2',
reportDto,
);
expect(result.passed).toBe(true);
expect(result.timeLimit).toBe(60);
expect(result.withinTimeLimit).toBe(true);
});
it('should return existing result when already passed (idempotent)', async () => {
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 };
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
mockParticipant,
);
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
{
id: 'progress-uuid-1',
participantId: 'participant-uuid-1',
levelId: 'level-1',
passed: true,
timeSpent: 25,
completedAt: new Date(),
} as ShareLevelProgress,
);
const result = await service.reportLevelProgress(
'user-uuid-2',
reportDto,
);
expect(result.passed).toBe(true);
expect(result.withinTimeLimit).toBe(true);
expect(mockShareLevelProgressRepository.save).not.toHaveBeenCalled();
});
it('should report withinTimeLimit=false when time exceeds limit', async () => {
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 20 };
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
mockParticipant,
);
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
null,
);
const newProgress: Partial<ShareLevelProgress> = {
participantId: 'participant-uuid-1',
levelId: 'level-1',
passed: true,
timeSpent: 30,
};
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
const result = await service.reportLevelProgress('user-uuid-2', {
...reportDto,
timeSpent: 30,
});
expect(result.passed).toBe(true);
expect(result.withinTimeLimit).toBe(false);
});
it('should report withinTimeLimit=true when level has no time limit', async () => {
const levelNoTimeLimit = { ...mockLevels[1], timeLimit: null };
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockLevelRepository.findById.mockResolvedValue(levelNoTimeLimit);
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
mockParticipant,
);
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
null,
);
const newProgress: Partial<ShareLevelProgress> = {
participantId: 'participant-uuid-1',
levelId: 'level-2',
passed: true,
timeSpent: 999,
};
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
const result = await service.reportLevelProgress('user-uuid-2', {
shareCode: 'ABCD1234',
levelId: 'level-2',
passed: true,
timeSpent: 999,
});
expect(result.withinTimeLimit).toBe(true);
});
it('should create participant if not exists when reporting progress', async () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockLevelRepository.findById.mockResolvedValue(mockLevels[0]);
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
null,
);
const createdParticipant = { ...mockParticipant };
mockShareParticipantRepository.create.mockReturnValue(createdParticipant);
mockShareParticipantRepository.save.mockResolvedValue(createdParticipant);
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
null,
);
mockShareLevelProgressRepository.create.mockReturnValue({} as any);
mockShareLevelProgressRepository.save.mockResolvedValue({} as any);
await service.reportLevelProgress('user-uuid-2', reportDto);
expect(mockShareParticipantRepository.create).toHaveBeenCalledWith({
shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-2',
});
expect(mockShareParticipantRepository.save).toHaveBeenCalled();
});
it('should throw NotFoundException when share not found', async () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
await expect(
service.reportLevelProgress('user-uuid-2', reportDto),
).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException when level not found', async () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockLevelRepository.findById.mockResolvedValue(null);
await expect(
service.reportLevelProgress('user-uuid-2', reportDto),
).rejects.toThrow(NotFoundException);
});
it('should update existing progress when not yet passed', async () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockLevelRepository.findById.mockResolvedValue(mockLevels[0]);
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
mockParticipant,
);
const existingProgress = {
id: 'progress-uuid-1',
participantId: 'participant-uuid-1',
levelId: 'level-1',
passed: false,
timeSpent: 15,
completedAt: new Date('2026-01-01'),
} as ShareLevelProgress;
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
existingProgress,
);
mockShareLevelProgressRepository.save.mockResolvedValue({
...existingProgress,
passed: true,
timeSpent: 30,
});
const result = await service.reportLevelProgress(
'user-uuid-2',
reportDto,
);
expect(result.passed).toBe(true);
expect(mockShareLevelProgressRepository.save).toHaveBeenCalled();
});
});
});