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,65 @@
import { ApiResponseDto } from './api-response.dto';
describe('ApiResponseDto', () => {
describe('success', () => {
it('should create a success response with data', () => {
const data = { id: '1', name: 'test' };
const response = ApiResponseDto.success(data);
expect(response.success).toBe(true);
expect(response.data).toEqual(data);
expect(response.message).toBeNull();
expect(response.timestamp).toBeInstanceOf(Date);
});
it('should create a success response with null data', () => {
const response = ApiResponseDto.success(null);
expect(response.success).toBe(true);
expect(response.data).toBeNull();
expect(response.message).toBeNull();
});
it('should create a success response with array data', () => {
const data = [1, 2, 3];
const response = ApiResponseDto.success(data);
expect(response.success).toBe(true);
expect(response.data).toEqual([1, 2, 3]);
});
});
describe('error', () => {
it('should create an error response with message', () => {
const response = ApiResponseDto.error('Something went wrong');
expect(response.success).toBe(false);
expect(response.data).toBeNull();
expect(response.message).toBe('Something went wrong');
expect(response.timestamp).toBeInstanceOf(Date);
});
it('should create an error response with Chinese message', () => {
const response = ApiResponseDto.error('积分不足');
expect(response.success).toBe(false);
expect(response.message).toBe('积分不足');
});
});
describe('constructor', () => {
it('should set default message to null', () => {
const dto = new ApiResponseDto(true, 'data');
expect(dto.message).toBeNull();
});
it('should set all properties correctly', () => {
const dto = new ApiResponseDto(false, null, 'error message');
expect(dto.success).toBe(false);
expect(dto.data).toBeNull();
expect(dto.message).toBe('error message');
});
});
});

View File

@@ -0,0 +1,97 @@
import { HttpExceptionFilter } from './http-exception.filter';
import {
HttpException,
HttpStatus,
ArgumentsHost,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
describe('HttpExceptionFilter', () => {
let filter: HttpExceptionFilter;
const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus });
const mockGetRequest = jest
.fn()
.mockReturnValue({ url: '/api/v1/test' });
const mockHost: ArgumentsHost = {
switchToHttp: () => ({
getResponse: mockGetResponse,
getRequest: mockGetRequest,
}),
} as unknown as ArgumentsHost;
beforeEach(() => {
filter = new HttpExceptionFilter();
jest.clearAllMocks();
mockStatus.mockReturnValue({ json: mockJson });
});
it('should handle HttpException with string response', () => {
const exception = new HttpException('Not Found', HttpStatus.NOT_FOUND);
filter.catch(exception, mockHost);
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
expect(mockJson).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
data: null,
path: '/api/v1/test',
}),
);
});
it('should handle BadRequestException with object response', () => {
const exception = new BadRequestException('参数错误');
filter.catch(exception, mockHost);
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(mockJson).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
path: '/api/v1/test',
}),
);
});
it('should handle NotFoundException', () => {
const exception = new NotFoundException('资源不存在');
filter.catch(exception, mockHost);
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
});
it('should handle generic Error with 500 status', () => {
const exception = new Error('Unexpected error');
filter.catch(exception, mockHost);
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(mockJson).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Unexpected error',
path: '/api/v1/test',
}),
);
});
it('should handle unknown exception with 500 status', () => {
filter.catch('unexpected string error', mockHost);
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(mockJson).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Internal server error',
path: '/api/v1/test',
}),
);
});
});

View File

@@ -0,0 +1,79 @@
import { JwtAuthGuard, JwtPayload } from './jwt-auth.guard';
import { JwtService } from '@nestjs/jwt';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
const mockJwtService = {
verifyAsync: jest.fn(),
};
beforeEach(() => {
guard = new JwtAuthGuard(mockJwtService as unknown as JwtService);
});
afterEach(() => {
jest.clearAllMocks();
});
function createMockContext(authHeader?: string): ExecutionContext {
const mockRequest: Record<string, unknown> = {
headers: authHeader ? { authorization: authHeader } : {},
};
return {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as unknown as ExecutionContext;
}
it('should allow access with valid Bearer token', async () => {
const payload: JwtPayload = { sub: 'user-1', openid: 'wx-openid' };
mockJwtService.verifyAsync.mockResolvedValue(payload);
const context = createMockContext('Bearer valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-token');
});
it('should attach user payload to request', async () => {
const payload: JwtPayload = { sub: 'user-1', openid: 'wx-openid' };
mockJwtService.verifyAsync.mockResolvedValue(payload);
const context = createMockContext('Bearer valid-token');
const request = context.switchToHttp().getRequest() as Record<string, unknown>;
await guard.canActivate(context);
expect(request.user).toEqual(payload);
});
it('should throw UnauthorizedException when no Authorization header', async () => {
const context = createMockContext();
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
it('should throw UnauthorizedException when token type is not Bearer', async () => {
const context = createMockContext('Basic some-token');
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
it('should throw UnauthorizedException when token is invalid', async () => {
mockJwtService.verifyAsync.mockRejectedValue(new Error('invalid token'));
const context = createMockContext('Bearer invalid-token');
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
});

View File

@@ -0,0 +1,120 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
describe('AuthController', () => {
let controller: AuthController;
const mockUser: JwtPayload = {
sub: 'user-uuid-1',
openid: 'wx-openid-123',
};
const mockAuthService = {
wxLogin: jest.fn(),
getUserAssets: jest.fn(),
consumePoint: jest.fn(),
earnPoint: jest.fn(),
getGameData: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: JwtService, useValue: { verifyAsync: jest.fn() } },
],
}).compile();
controller = module.get<AuthController>(AuthController);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('wxLogin', () => {
it('should return success response with token and user info', async () => {
const loginResponse = {
token: 'jwt-token',
user: { id: 'user-uuid-1', nickname: 'Test', points: 10 },
};
mockAuthService.wxLogin.mockResolvedValue(loginResponse);
const result = await controller.wxLogin({ code: 'wx-code-123' });
expect(result).toBeInstanceOf(ApiResponseDto);
expect(result.success).toBe(true);
expect(result.data).toEqual(loginResponse);
expect(mockAuthService.wxLogin).toHaveBeenCalledWith('wx-code-123');
});
});
describe('getUserAssets', () => {
it('should return success response with user points', async () => {
mockAuthService.getUserAssets.mockResolvedValue({ points: 10 });
const result = await controller.getUserAssets(mockUser);
expect(result.success).toBe(true);
expect(result.data).toEqual({ points: 10 });
expect(mockAuthService.getUserAssets).toHaveBeenCalledWith('user-uuid-1');
});
});
describe('consumePoint', () => {
it('should return success response with updated points', async () => {
mockAuthService.consumePoint.mockResolvedValue({ points: 9 });
const dto = { reason: 'hint_unlock' as const, levelId: 'level-1', hintIndex: 2 };
const result = await controller.consumePoint(mockUser, dto);
expect(result.success).toBe(true);
expect(result.data).toEqual({ points: 9 });
expect(mockAuthService.consumePoint).toHaveBeenCalledWith(
'user-uuid-1',
dto,
);
});
});
describe('earnPoint', () => {
it('should return success response with updated points', async () => {
mockAuthService.earnPoint.mockResolvedValue({ points: 11 });
const dto = {
reason: 'level_complete' as const,
levelId: 'level-1',
timeSpent: 30,
};
const result = await controller.earnPoint(mockUser, dto);
expect(result.success).toBe(true);
expect(result.data).toEqual({ points: 11 });
expect(mockAuthService.earnPoint).toHaveBeenCalledWith(
'user-uuid-1',
dto,
);
});
});
describe('getGameData', () => {
it('should return success response with game data', async () => {
const gameData = {
user: { id: 'user-uuid-1', points: 10 },
completedLevelIds: ['level-1', 'level-2'],
};
mockAuthService.getGameData.mockResolvedValue(gameData);
const result = await controller.getGameData(mockUser);
expect(result.success).toBe(true);
expect(result.data).toEqual(gameData);
expect(mockAuthService.getGameData).toHaveBeenCalledWith('user-uuid-1');
});
});
});

View File

@@ -0,0 +1,293 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import {
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import axios from 'axios';
import { AuthService } from './auth.service';
import { UserRepository } from './repositories/user.repository';
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
import { User } from './entities/user.entity';
import { UserLevelProgress } from './entities/user-level-progress.entity';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('AuthService', () => {
let service: AuthService;
const mockUser: User = {
id: 'user-uuid-1',
openid: 'wx-openid-123',
sessionKey: 'session-key-abc',
nickname: 'TestUser',
avatarUrl: null,
points: 10,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockLevelProgress: UserLevelProgress = {
id: 'progress-uuid-1',
userId: 'user-uuid-1',
levelId: 'level-1',
user: mockUser,
timeSpent: 30,
completedAt: new Date('2026-01-02'),
};
const mockUserRepository = {
findById: jest.fn(),
findByOpenid: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockUserLevelProgressRepository = {
findByUserId: jest.fn(),
findByUserAndLevel: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockJwtService = {
signAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: string) => {
const config: Record<string, string> = {
WX_APPID: 'test-appid',
WX_SECRET: 'test-secret',
};
return config[key] ?? defaultValue ?? '';
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: JwtService, useValue: mockJwtService },
{ provide: UserRepository, useValue: mockUserRepository },
{
provide: UserLevelProgressRepository,
useValue: mockUserLevelProgressRepository,
},
],
}).compile();
service = module.get<AuthService>(AuthService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('wxLogin', () => {
it('should create a new user and return JWT token on first login', async () => {
const newUser = { ...mockUser, points: 10 };
mockedAxios.get.mockResolvedValue({
data: { openid: 'wx-openid-123', session_key: 'session-key-abc' },
});
mockUserRepository.findByOpenid.mockResolvedValue(null);
mockUserRepository.create.mockReturnValue(newUser);
mockUserRepository.save.mockResolvedValue(newUser);
mockJwtService.signAsync.mockResolvedValue('jwt-token-xyz');
const result = await service.wxLogin('wx-code-123');
expect(result.token).toBe('jwt-token-xyz');
expect(result.user.id).toBe('user-uuid-1');
expect(result.user.points).toBe(10);
expect(mockUserRepository.create).toHaveBeenCalledWith({
openid: 'wx-openid-123',
sessionKey: 'session-key-abc',
points: 10,
});
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
sub: 'user-uuid-1',
openid: 'wx-openid-123',
});
});
it('should return existing user and update session_key on repeat login', async () => {
const existingUser = { ...mockUser };
const updatedUser = { ...existingUser, sessionKey: 'new-session-key' };
mockedAxios.get.mockResolvedValue({
data: { openid: 'wx-openid-123', session_key: 'new-session-key' },
});
mockUserRepository.findByOpenid.mockResolvedValue(existingUser);
mockUserRepository.save.mockResolvedValue(updatedUser);
mockJwtService.signAsync.mockResolvedValue('jwt-token-abc');
const result = await service.wxLogin('wx-code-456');
expect(result.token).toBe('jwt-token-abc');
expect(result.user.id).toBe('user-uuid-1');
expect(mockUserRepository.create).not.toHaveBeenCalled();
expect(mockUserRepository.save).toHaveBeenCalled();
});
it('should throw UnauthorizedException when WeChat API returns error', async () => {
mockedAxios.get.mockResolvedValue({
data: { errcode: 40029, errmsg: 'invalid code' },
});
await expect(service.wxLogin('invalid-code')).rejects.toThrow(
UnauthorizedException,
);
});
it('should throw UnauthorizedException when WeChat API call fails', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network error'));
await expect(service.wxLogin('any-code')).rejects.toThrow(
UnauthorizedException,
);
});
});
describe('getUserAssets', () => {
it('should return user points', async () => {
mockUserRepository.findById.mockResolvedValue(mockUser);
const result = await service.getUserAssets('user-uuid-1');
expect(result.points).toBe(10);
expect(mockUserRepository.findById).toHaveBeenCalledWith('user-uuid-1');
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
await expect(service.getUserAssets('nonexistent')).rejects.toThrow(
UnauthorizedException,
);
});
});
describe('consumePoint', () => {
it('should deduct 1 point and return updated points', async () => {
const user = { ...mockUser, points: 5 };
const savedUser = { ...user, points: 4 };
mockUserRepository.findById.mockResolvedValue(user);
mockUserRepository.save.mockResolvedValue(savedUser);
const result = await service.consumePoint('user-uuid-1', {
reason: 'hint_unlock',
levelId: 'level-1',
hintIndex: 2,
});
expect(result.points).toBe(4);
expect(mockUserRepository.save).toHaveBeenCalled();
});
it('should throw BadRequestException when points are 0', async () => {
mockUserRepository.findById.mockResolvedValue({
...mockUser,
points: 0,
});
await expect(
service.consumePoint('user-uuid-1', {
reason: 'hint_unlock',
}),
).rejects.toThrow(BadRequestException);
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
await expect(
service.consumePoint('nonexistent', { reason: 'hint_unlock' }),
).rejects.toThrow(UnauthorizedException);
});
});
describe('earnPoint', () => {
const earnDto = {
reason: 'level_complete' as const,
levelId: 'level-1',
timeSpent: 30,
};
it('should award 1 point for first-time level completion', async () => {
const user = { ...mockUser, points: 10 };
mockUserRepository.findById.mockResolvedValue(user);
mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue(null);
mockUserLevelProgressRepository.create.mockReturnValue(mockLevelProgress);
mockUserLevelProgressRepository.save.mockResolvedValue(mockLevelProgress);
mockUserRepository.save.mockResolvedValue({ ...user, points: 11 });
const result = await service.earnPoint('user-uuid-1', earnDto);
expect(result.points).toBe(11);
expect(mockUserLevelProgressRepository.create).toHaveBeenCalledWith({
userId: 'user-uuid-1',
levelId: 'level-1',
timeSpent: 30,
});
});
it('should not award duplicate points for already completed level', async () => {
const user = { ...mockUser, points: 10 };
mockUserRepository.findById.mockResolvedValue(user);
mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue(
mockLevelProgress,
);
const result = await service.earnPoint('user-uuid-1', earnDto);
expect(result.points).toBe(10);
expect(mockUserLevelProgressRepository.create).not.toHaveBeenCalled();
expect(mockUserRepository.save).not.toHaveBeenCalled();
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
await expect(
service.earnPoint('nonexistent', earnDto),
).rejects.toThrow(UnauthorizedException);
});
});
describe('getGameData', () => {
it('should return user info and completed level IDs', async () => {
mockUserRepository.findById.mockResolvedValue(mockUser);
mockUserLevelProgressRepository.findByUserId.mockResolvedValue([
{ ...mockLevelProgress, levelId: 'level-1' },
{ ...mockLevelProgress, levelId: 'level-2' },
]);
const result = await service.getGameData('user-uuid-1');
expect(result.user.id).toBe('user-uuid-1');
expect(result.user.points).toBe(10);
expect(result.completedLevelIds).toEqual(['level-1', 'level-2']);
});
it('should return empty completedLevelIds when no progress', async () => {
mockUserRepository.findById.mockResolvedValue(mockUser);
mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]);
const result = await service.getGameData('user-uuid-1');
expect(result.completedLevelIds).toEqual([]);
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]);
await expect(service.getGameData('nonexistent')).rejects.toThrow(
UnauthorizedException,
);
});
});
});

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();
});
});
});

View File

@@ -0,0 +1,133 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WechatGameController } from './wechat-game.controller';
import { WechatGameService } from './wechat-game.service';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
describe('WechatGameController', () => {
let controller: WechatGameController;
const mockWechatGameService = {
getAllConfigs: jest.fn(),
getConfigByKey: jest.fn(),
getAllLevels: jest.fn(),
getLevelById: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [WechatGameController],
providers: [
{ provide: WechatGameService, useValue: mockWechatGameService },
],
}).compile();
controller = module.get<WechatGameController>(WechatGameController);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getAllConfigs', () => {
it('should return success response with config list', async () => {
const configList = {
configs: [
{
id: 'config-1',
configKey: 'game_speed',
configValue: '1.5',
description: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
],
total: 1,
};
mockWechatGameService.getAllConfigs.mockResolvedValue(configList);
const result = await controller.getAllConfigs();
expect(result).toBeInstanceOf(ApiResponseDto);
expect(result.success).toBe(true);
expect(result.data).toEqual(configList);
});
});
describe('getConfigByKey', () => {
it('should return success response with config', async () => {
const config = {
id: 'config-1',
configKey: 'game_speed',
configValue: '1.5',
description: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockWechatGameService.getConfigByKey.mockResolvedValue(config);
const result = await controller.getConfigByKey('game_speed');
expect(result.success).toBe(true);
expect(result.data).toEqual(config);
expect(mockWechatGameService.getConfigByKey).toHaveBeenCalledWith(
'game_speed',
);
});
});
describe('getAllLevels', () => {
it('should return success response with level list', async () => {
const levelList = {
levels: [
{
level: 1,
id: 'level-1',
imageUrl: 'https://example.com/1.jpg',
answer: '答案',
hint1: null,
hint2: null,
hint3: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
],
total: 1,
};
mockWechatGameService.getAllLevels.mockResolvedValue(levelList);
const result = await controller.getAllLevels();
expect(result.success).toBe(true);
expect(result.data).toEqual(levelList);
});
});
describe('getLevelById', () => {
it('should return success response with level', async () => {
const level = {
level: 1,
id: 'level-1',
imageUrl: 'https://example.com/1.jpg',
answer: '答案',
hint1: null,
hint2: null,
hint3: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
mockWechatGameService.getLevelById.mockResolvedValue(level);
const result = await controller.getLevelById('level-1');
expect(result.success).toBe(true);
expect(result.data).toEqual(level);
expect(mockWechatGameService.getLevelById).toHaveBeenCalledWith(
'level-1',
);
});
});
});

View File

@@ -1,41 +1,69 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { WechatGameService } from './wechat-game.service';
import { GameConfigRepository } from './repositories/game-config.repository';
import { NotFoundException } from '@nestjs/common';
import { LevelRepository } from './repositories/level.repository';
import { GameConfig } from './entities/game-config.entity';
import { Level } from './entities/level.entity';
describe('WechatGameService', () => {
let service: WechatGameService;
let repository: GameConfigRepository;
const mockGameConfig: GameConfig = {
id: 'test-uuid',
id: 'config-uuid-1',
configKey: 'game_speed',
configValue: '1.5',
description: 'Game speed multiplier',
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockRepository = {
const mockLevel: Level = {
id: 'level-1',
imageUrl: 'https://example.com/meme1.jpg',
answer: '答案一',
hint1: '提示1',
hint2: '提示2',
hint3: null,
sortOrder: 0,
timeLimit: 60,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockLevel2: Level = {
id: 'level-2',
imageUrl: 'https://example.com/meme2.jpg',
answer: '答案二',
hint1: '提示A',
hint2: null,
hint3: null,
sortOrder: 1,
timeLimit: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockGameConfigRepository = {
findActiveConfigs: jest.fn(),
findByKey: jest.fn(),
};
const mockLevelRepository = {
findAllOrdered: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WechatGameService,
{
provide: GameConfigRepository,
useValue: mockRepository,
},
{ provide: GameConfigRepository, useValue: mockGameConfigRepository },
{ provide: LevelRepository, useValue: mockLevelRepository },
],
}).compile();
service = module.get<WechatGameService>(WechatGameService);
repository = module.get<GameConfigRepository>(GameConfigRepository);
});
afterEach(() => {
@@ -44,18 +72,20 @@ describe('WechatGameService', () => {
describe('getAllConfigs', () => {
it('should return all active configs', async () => {
mockRepository.findActiveConfigs.mockResolvedValue([mockGameConfig]);
mockGameConfigRepository.findActiveConfigs.mockResolvedValue([
mockGameConfig,
]);
const result = await service.getAllConfigs();
expect(result.configs).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.configs[0].configKey).toBe('game_speed');
expect(mockRepository.findActiveConfigs).toHaveBeenCalled();
expect(result.configs[0].configValue).toBe('1.5');
});
it('should return empty array when no configs found', async () => {
mockRepository.findActiveConfigs.mockResolvedValue([]);
mockGameConfigRepository.findActiveConfigs.mockResolvedValue([]);
const result = await service.getAllConfigs();
@@ -66,21 +96,74 @@ describe('WechatGameService', () => {
describe('getConfigByKey', () => {
it('should return config by key', async () => {
mockRepository.findByKey.mockResolvedValue(mockGameConfig);
mockGameConfigRepository.findByKey.mockResolvedValue(mockGameConfig);
const result = await service.getConfigByKey('game_speed');
expect(result.configKey).toBe('game_speed');
expect(result.configValue).toBe('1.5');
expect(mockRepository.findByKey).toHaveBeenCalledWith('game_speed');
expect(mockGameConfigRepository.findByKey).toHaveBeenCalledWith(
'game_speed',
);
});
it('should throw NotFoundException when config not found', async () => {
mockRepository.findByKey.mockResolvedValue(null);
mockGameConfigRepository.findByKey.mockResolvedValue(null);
await expect(service.getConfigByKey('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});
describe('getAllLevels', () => {
it('should return all levels with 1-indexed level numbers', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([
mockLevel,
mockLevel2,
]);
const result = await service.getAllLevels();
expect(result.levels).toHaveLength(2);
expect(result.total).toBe(2);
expect(result.levels[0].level).toBe(1);
expect(result.levels[0].id).toBe('level-1');
expect(result.levels[0].answer).toBe('答案一');
expect(result.levels[1].level).toBe(2);
expect(result.levels[1].id).toBe('level-2');
});
it('should return empty array when no levels exist', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([]);
const result = await service.getAllLevels();
expect(result.levels).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('getLevelById', () => {
it('should return level with correct level number', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([
mockLevel,
mockLevel2,
]);
const result = await service.getLevelById('level-2');
expect(result.id).toBe('level-2');
expect(result.level).toBe(2);
expect(result.answer).toBe('答案二');
});
it('should throw NotFoundException when level not found', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([mockLevel]);
await expect(service.getLevelById('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});
});