feat: 支持单元测试
This commit is contained in:
65
src/common/dto/api-response.dto.spec.ts
Normal file
65
src/common/dto/api-response.dto.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
97
src/common/filters/http-exception.filter.spec.ts
Normal file
97
src/common/filters/http-exception.filter.spec.ts
Normal 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
79
src/common/guards/jwt-auth.guard.spec.ts
Normal file
79
src/common/guards/jwt-auth.guard.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
120
src/modules/auth/auth.controller.spec.ts
Normal file
120
src/modules/auth/auth.controller.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
293
src/modules/auth/auth.service.spec.ts
Normal file
293
src/modules/auth/auth.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
119
src/modules/share/share.controller.spec.ts
Normal file
119
src/modules/share/share.controller.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
409
src/modules/share/share.service.spec.ts
Normal file
409
src/modules/share/share.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
133
src/modules/wechat-game/wechat-game.controller.spec.ts
Normal file
133
src/modules/wechat-game/wechat-game.controller.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user