/** * LoginService Zulip账号创建属性测试 * * 功能描述: * - 测试用户注册时Zulip账号创建的一致性 * - 验证账号关联和数据完整性 * - 测试失败回滚机制 * * 属性测试: * - 属性 13: Zulip账号创建一致性 * - 验证需求: 账号创建成功率和数据一致性 * * 最近修改: * - 2026-01-08: 文件重命名 - 修正kebab-case为snake_case命名规范 (修改者: moyin) * * @author angjustinl * @version 1.0.1 * @since 2025-01-05 * @lastModified 2026-01-08 */ import { Test, TestingModule } from '@nestjs/testing'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as fc from 'fast-check'; import { LoginService } from './login.service'; import { LoginCoreService, RegisterRequest } from '../../core/login_core/login_core.service'; import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service'; import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service'; import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service'; import { Users } from '../../core/db/users/users.entity'; describe('LoginService - Zulip账号创建属性测试', () => { let loginService: LoginService; let loginCoreService: jest.Mocked; let zulipAccountService: jest.Mocked; let zulipAccountsService: jest.Mocked; let apiKeySecurityService: jest.Mocked; // 测试用的模拟数据生成器 const validEmailArb = fc.string({ minLength: 5, maxLength: 50 }) .filter(s => s.includes('@') && s.includes('.')) .map(s => `test_${s.replace(/[^a-zA-Z0-9@._-]/g, '')}@example.com`); const validUsernameArb = fc.string({ minLength: 3, maxLength: 20 }) .filter(s => /^[a-zA-Z0-9_]+$/.test(s)); const validNicknameArb = fc.string({ minLength: 2, maxLength: 50 }) .filter(s => s.trim().length > 0); const validPasswordArb = fc.string({ minLength: 8, maxLength: 20 }) .filter(s => /[a-zA-Z]/.test(s) && /\d/.test(s)); const registerRequestArb = fc.record({ username: validUsernameArb, email: validEmailArb, nickname: validNicknameArb, password: validPasswordArb, }); beforeEach(async () => { // 创建模拟服务 const mockLoginCoreService = { register: jest.fn(), deleteUser: jest.fn(), generateTokenPair: jest.fn(), }; const mockZulipAccountService = { initializeAdminClient: jest.fn(), createZulipAccount: jest.fn(), linkGameAccount: jest.fn(), }; const mockZulipAccountsService = { findByGameUserId: jest.fn(), create: jest.fn(), deleteByGameUserId: jest.fn(), }; const mockApiKeySecurityService = { storeApiKey: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ LoginService, { provide: LoginCoreService, useValue: mockLoginCoreService, }, { provide: ZulipAccountService, useValue: mockZulipAccountService, }, { provide: 'ZulipAccountsService', useValue: mockZulipAccountsService, }, { provide: ApiKeySecurityService, useValue: mockApiKeySecurityService, }, { provide: JwtService, useValue: { sign: jest.fn().mockReturnValue('mock_jwt_token'), signAsync: jest.fn().mockResolvedValue('mock_jwt_token'), verify: jest.fn(), decode: jest.fn(), }, }, { provide: ConfigService, useValue: { get: jest.fn((key: string) => { switch (key) { case 'JWT_SECRET': return 'test_jwt_secret_key_for_testing'; case 'JWT_EXPIRES_IN': return '7d'; default: return undefined; } }), }, }, { provide: 'UsersService', useValue: { findById: jest.fn(), findByUsername: jest.fn(), findByEmail: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, }, ], }).compile(); loginService = module.get(LoginService); loginCoreService = module.get(LoginCoreService); zulipAccountService = module.get(ZulipAccountService); zulipAccountsService = module.get('ZulipAccountsService'); apiKeySecurityService = module.get(ApiKeySecurityService); // 设置默认的mock返回值 const mockTokenPair = { access_token: 'mock_access_token', refresh_token: 'mock_refresh_token', expires_in: 604800, token_type: 'Bearer' }; loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair); // Mock LoginService 的 initializeZulipAdminClient 方法 jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); // 设置环境变量模拟 process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; }); afterEach(() => { jest.clearAllMocks(); // 清理环境变量 delete process.env.ZULIP_SERVER_URL; delete process.env.ZULIP_BOT_EMAIL; delete process.env.ZULIP_BOT_API_KEY; }); /** * 属性 13: Zulip账号创建一致性 * * 验证需求: 账号创建成功率和数据一致性 * * 测试内容: * 1. 成功注册时,游戏账号和Zulip账号都应该被创建 * 2. 账号关联信息应该正确存储 * 3. Zulip账号创建失败时,游戏账号应该被回滚 * 4. 数据一致性:邮箱、昵称等信息应该保持一致 */ describe('属性 13: Zulip账号创建一致性', () => { it('应该在成功注册时创建一致的游戏账号和Zulip账号', async () => { await fc.assert( fc.asyncProperty(registerRequestArb, async (registerRequest) => { // 准备测试数据 const mockGameUser: Users = { id: BigInt(Math.floor(Math.random() * 1000000)), username: registerRequest.username, email: registerRequest.email, nickname: registerRequest.nickname, password_hash: 'hashed_password', role: 1, created_at: new Date(), updated_at: new Date(), } as Users; const mockZulipResult = { success: true, userId: Math.floor(Math.random() * 1000000), email: registerRequest.email, apiKey: 'zulip_api_key_' + Math.random().toString(36), }; const mockZulipAccount = { id: mockGameUser.id.toString(), gameUserId: mockGameUser.id.toString(), zulipUserId: mockZulipResult.userId, zulipEmail: mockZulipResult.email, zulipFullName: registerRequest.nickname, zulipApiKeyEncrypted: 'encrypted_' + mockZulipResult.apiKey, status: 'active' as const, retryCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // 设置模拟行为 loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, }); zulipAccountsService.findByGameUserId.mockResolvedValue(null); zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); zulipAccountsService.create.mockResolvedValue(mockZulipAccount); zulipAccountService.linkGameAccount.mockResolvedValue(true); // 执行注册 const result = await loginService.register(registerRequest); // 验证结果 expect(result.success).toBe(true); expect(result.data?.user.username).toBe(registerRequest.username); expect(result.data?.user.email).toBe(registerRequest.email); expect(result.data?.user.nickname).toBe(registerRequest.nickname); expect(result.data?.is_new_user).toBe(true); // 验证Zulip管理员客户端初始化 expect(loginService['initializeZulipAdminClient']).toHaveBeenCalled(); // 验证游戏用户注册 expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); // 验证Zulip账号创建 expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ email: registerRequest.email, fullName: registerRequest.nickname, password: registerRequest.password, }); // 验证API Key存储 expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith( mockGameUser.id.toString(), mockZulipResult.apiKey ); // 验证账号关联创建 expect(zulipAccountsService.create).toHaveBeenCalledWith({ gameUserId: mockGameUser.id.toString(), zulipUserId: mockZulipResult.userId, zulipEmail: mockZulipResult.email, zulipFullName: registerRequest.nickname, zulipApiKeyEncrypted: 'stored_in_redis', status: 'active', }); // 验证内存关联 expect(zulipAccountService.linkGameAccount).toHaveBeenCalledWith( mockGameUser.id.toString(), mockZulipResult.userId, mockZulipResult.email, mockZulipResult.apiKey ); }), { numRuns: 100 } ); }); it('应该在Zulip账号创建失败时回滚游戏账号', async () => { await fc.assert( fc.asyncProperty(registerRequestArb, async (registerRequest) => { // 准备测试数据 const mockGameUser: Users = { id: BigInt(Math.floor(Math.random() * 1000000)), username: registerRequest.username, email: registerRequest.email, nickname: registerRequest.nickname, password_hash: 'hashed_password', role: 1, created_at: new Date(), updated_at: new Date(), } as Users; // 设置模拟行为 - Zulip账号创建失败 loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, }); zulipAccountsService.findByGameUserId.mockResolvedValue(null); zulipAccountService.createZulipAccount.mockResolvedValue({ success: false, error: 'Zulip服务器连接失败', errorCode: 'CONNECTION_FAILED', }); loginCoreService.deleteUser.mockResolvedValue(true); // 执行注册 const result = await loginService.register(registerRequest); // 验证结果 - 注册应该失败 expect(result.success).toBe(false); expect(result.message).toContain('Zulip账号创建失败'); // 验证游戏用户被创建 expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); // 验证Zulip账号创建尝试 expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ email: registerRequest.email, fullName: registerRequest.nickname, password: registerRequest.password, }); // 验证游戏用户被回滚删除 expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockGameUser.id); // 验证没有创建账号关联 expect(zulipAccountsService.create).not.toHaveBeenCalled(); expect(zulipAccountService.linkGameAccount).not.toHaveBeenCalled(); }), { numRuns: 100 } ); }); it('应该正确处理已存在Zulip账号关联的情况', async () => { await fc.assert( fc.asyncProperty(registerRequestArb, async (registerRequest) => { // 准备测试数据 const mockGameUser: Users = { id: BigInt(Math.floor(Math.random() * 1000000)), username: registerRequest.username, email: registerRequest.email, nickname: registerRequest.nickname, password_hash: 'hashed_password', role: 1, created_at: new Date(), updated_at: new Date(), } as Users; const existingZulipAccount = { id: Math.floor(Math.random() * 1000000).toString(), gameUserId: mockGameUser.id.toString(), zulipUserId: 12345, zulipEmail: registerRequest.email, zulipFullName: registerRequest.nickname, zulipApiKeyEncrypted: 'existing_encrypted_key', status: 'active' as const, retryCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // 设置模拟行为 - 已存在Zulip账号关联 loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, }); zulipAccountsService.findByGameUserId.mockResolvedValue(existingZulipAccount); // 执行注册 const result = await loginService.register(registerRequest); // 验证结果 - 注册应该成功 expect(result.success).toBe(true); expect(result.data?.user.username).toBe(registerRequest.username); // 验证游戏用户被创建 expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); // 验证检查了现有关联 expect(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id.toString()); // 验证没有尝试创建新的Zulip账号 expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); expect(zulipAccountsService.create).not.toHaveBeenCalled(); }), { numRuns: 100 } ); }); it('应该正确处理缺少邮箱或密码的注册请求', async () => { await fc.assert( fc.asyncProperty( fc.record({ username: validUsernameArb, nickname: validNicknameArb, email: fc.option(validEmailArb, { nil: undefined }), password: fc.option(validPasswordArb, { nil: undefined }), }), async (registerRequest) => { // 只测试缺少邮箱或密码的情况 if (registerRequest.email && registerRequest.password) { return; // 跳过完整数据的情况 } // 准备测试数据 const mockGameUser: Users = { id: BigInt(Math.floor(Math.random() * 1000000)), username: registerRequest.username, email: registerRequest.email || null, nickname: registerRequest.nickname, password_hash: registerRequest.password ? 'hashed_password' : null, role: 1, created_at: new Date(), updated_at: new Date(), } as Users; // 设置模拟行为 loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, }); // 执行注册 const result = await loginService.register(registerRequest as RegisterRequest); // 验证结果 - 注册应该成功,但跳过Zulip账号创建 expect(result.success).toBe(true); expect(result.data?.user.username).toBe(registerRequest.username); expect(result.data?.message).toBe('注册成功'); // 不包含Zulip创建信息 // 验证游戏用户被创建 expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); // 验证没有尝试创建Zulip账号 expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); expect(zulipAccountsService.create).not.toHaveBeenCalled(); } ), { numRuns: 50 } ); }); it('应该正确处理Zulip管理员客户端初始化失败', async () => { await fc.assert( fc.asyncProperty(registerRequestArb, async (registerRequest) => { // 设置模拟行为 - 管理员客户端初始化失败 jest.spyOn(loginService as any, 'initializeZulipAdminClient') .mockRejectedValue(new Error('Zulip管理员客户端初始化失败')); // 执行注册 const result = await loginService.register(registerRequest); // 验证结果 - 注册应该失败 expect(result.success).toBe(false); expect(result.message).toContain('Zulip管理员客户端初始化失败'); // 验证没有尝试创建游戏用户 expect(loginCoreService.register).not.toHaveBeenCalled(); // 验证没有尝试创建Zulip账号 expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); // 恢复 mock jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); }), { numRuns: 50 } ); }); it('应该正确处理环境变量缺失的情况', async () => { await fc.assert( fc.asyncProperty(registerRequestArb, async (registerRequest) => { // 清除环境变量 delete process.env.ZULIP_SERVER_URL; delete process.env.ZULIP_BOT_EMAIL; delete process.env.ZULIP_BOT_API_KEY; // 重新设置 mock 以模拟环境变量缺失的错误 jest.spyOn(loginService as any, 'initializeZulipAdminClient') .mockRejectedValue(new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY')); // 执行注册 const result = await loginService.register(registerRequest); // 验证结果 - 注册应该失败 expect(result.success).toBe(false); expect(result.message).toContain('Zulip管理员配置不完整'); // 验证没有尝试创建游戏用户 expect(loginCoreService.register).not.toHaveBeenCalled(); // 恢复环境变量和 mock process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); }), { numRuns: 30 } ); }); }); /** * 数据一致性验证测试 * * 验证游戏账号和Zulip账号之间的数据一致性 */ describe('数据一致性验证', () => { it('应该确保游戏账号和Zulip账号使用相同的邮箱和昵称', async () => { await fc.assert( fc.asyncProperty(registerRequestArb, async (registerRequest) => { // 准备测试数据 const mockGameUser: Users = { id: BigInt(Math.floor(Math.random() * 1000000)), username: registerRequest.username, email: registerRequest.email, nickname: registerRequest.nickname, password_hash: 'hashed_password', role: 1, created_at: new Date(), updated_at: new Date(), } as Users; const mockZulipResult = { success: true, userId: Math.floor(Math.random() * 1000000), email: registerRequest.email, apiKey: 'zulip_api_key_' + Math.random().toString(36), }; // 设置模拟行为 loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, }); zulipAccountsService.findByGameUserId.mockResolvedValue(null); zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); zulipAccountsService.create.mockResolvedValue({} as any); zulipAccountService.linkGameAccount.mockResolvedValue(true); // 执行注册 await loginService.register(registerRequest); // 验证Zulip账号创建时使用了正确的数据 expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ email: registerRequest.email, // 相同的邮箱 fullName: registerRequest.nickname, // 相同的昵称 password: registerRequest.password, // 相同的密码 }); // 验证账号关联存储了正确的数据 expect(zulipAccountsService.create).toHaveBeenCalledWith( expect.objectContaining({ gameUserId: mockGameUser.id.toString(), zulipUserId: mockZulipResult.userId, zulipEmail: registerRequest.email, // 相同的邮箱 zulipFullName: registerRequest.nickname, // 相同的昵称 zulipApiKeyEncrypted: 'stored_in_redis', status: 'active', }) ); }), { numRuns: 100 } ); }); }); });