using FsCheck; using FsCheck.Xunit; using HoneyBox.Core.Interfaces; using HoneyBox.Core.Services; using HoneyBox.Model.Data; using HoneyBox.Model.Entities; using HoneyBox.Model.Models.Auth; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Moq; using Xunit; namespace HoneyBox.Tests.Services; /// /// AuthService手机号绑定属性测试 /// 测试手机号绑定相关的核心属性 /// public class AuthServiceBindMobilePropertyTests { private HoneyBoxDbContext CreateInMemoryDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning)) .Options; return new HoneyBoxDbContext(options); } private (AuthService authService, Mock wechatMock, Mock redisMock) CreateAuthService(HoneyBoxDbContext dbContext) { var mockUserLogger = new Mock>(); var userService = new UserService(dbContext, mockUserLogger.Object); var jwtSettings = new JwtSettings { Secret = "your-secret-key-must-be-at-least-32-characters-long-for-hs256", Issuer = "HoneyBox", Audience = "HoneyBoxUsers", ExpirationMinutes = 1440, RefreshTokenExpirationDays = 7 }; var mockJwtLogger = new Mock>(); var jwtService = new JwtService(jwtSettings, mockJwtLogger.Object); var mockWechatService = new Mock(); var mockIpLocationService = new Mock(); var mockRedisService = new Mock(); var mockAuthLogger = new Mock>(); mockIpLocationService.Setup(x => x.GetLocationAsync(It.IsAny())) .ReturnsAsync(new IpLocationResult { Success = true, Province = "北京", City = "北京", Adcode = "110000" }); var authService = new AuthService( dbContext, userService, jwtService, mockWechatService.Object, mockIpLocationService.Object, mockRedisService.Object, jwtSettings, mockAuthLogger.Object); return (authService, mockWechatService, mockRedisService); } /// /// Property 12: 手机号绑定验证码验证 /// For any mobile binding request: /// - If verification code is correct, binding should succeed /// - If verification code is incorrect, binding should fail with "验证码错误" /// Validates: Requirements 5.1, 5.5 /// Feature: user-auth-migration, Property 12: 手机号绑定验证码验证 /// [Property(MaxTest = 100)] public async Task BindMobileVerificationCodeValidation() { var dbContext = CreateInMemoryDbContext(); var (authService, _, redisMock) = CreateAuthService(dbContext); var mobile = "138" + Random.Shared.Next(10000000, 99999999).ToString(); var correctCode = "123456"; var wrongCode = "654321"; // Create a user to bind mobile to var user = new User { OpenId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8), Uid = "uid123", Nickname = "TestUser", HeadImg = "https://example.com/avatar.jpg", Status = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await dbContext.Users.AddAsync(user); await dbContext.SaveChangesAsync(); // Test 1: Correct code should succeed redisMock.Setup(x => x.GetStringAsync($"sms:code:{mobile}")) .ReturnsAsync(correctCode); redisMock.Setup(x => x.DeleteAsync($"sms:code:{mobile}")) .Returns(Task.CompletedTask); var successResult = await authService.BindMobileAsync(user.Id, mobile, correctCode); var userAfterBind = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == user.Id); var bindSuccess = userAfterBind?.Mobile == mobile; // Test 2: Wrong code should fail var dbContext2 = CreateInMemoryDbContext(); var (authService2, _, redisMock2) = CreateAuthService(dbContext2); var user2 = new User { OpenId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8), Uid = "uid456", Nickname = "TestUser2", HeadImg = "https://example.com/avatar.jpg", Status = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await dbContext2.Users.AddAsync(user2); await dbContext2.SaveChangesAsync(); redisMock2.Setup(x => x.GetStringAsync($"sms:code:{mobile}")) .ReturnsAsync(correctCode); bool wrongCodeFailed = false; try { await authService2.BindMobileAsync(user2.Id, mobile, wrongCode); } catch (InvalidOperationException ex) when (ex.Message == "验证码错误") { wrongCodeFailed = true; } return bindSuccess && wrongCodeFailed; } /// /// Property 13: 账户合并正确性 /// When a mobile number is already bound to another user: /// - The current user's openid should be migrated to the mobile user /// - The current user record should be deleted /// - A new token should be returned for the mobile user /// Validates: Requirements 5.2, 5.3 /// Feature: user-auth-migration, Property 13: 账户合并正确性 /// [Property(MaxTest = 100)] public async Task AccountMergeCorrectness() { var dbContext = CreateInMemoryDbContext(); var (authService, _, redisMock) = CreateAuthService(dbContext); var mobile = "138" + Random.Shared.Next(10000000, 99999999).ToString(); var correctCode = "123456"; var currentUserOpenId = "openid_current_" + Guid.NewGuid().ToString().Substring(0, 8); // Create current user (WeChat user without mobile) var currentUser = new User { OpenId = currentUserOpenId, Uid = "uid_current", Nickname = "CurrentUser", HeadImg = "https://example.com/avatar1.jpg", Status = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await dbContext.Users.AddAsync(currentUser); // Create mobile user (user with mobile already bound) var mobileUser = new User { OpenId = "openid_mobile", Mobile = mobile, Uid = "uid_mobile", Nickname = "MobileUser", HeadImg = "https://example.com/avatar2.jpg", Status = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await dbContext.Users.AddAsync(mobileUser); await dbContext.SaveChangesAsync(); var currentUserId = currentUser.Id; var mobileUserId = mobileUser.Id; // Setup Redis mock redisMock.Setup(x => x.GetStringAsync($"sms:code:{mobile}")) .ReturnsAsync(correctCode); redisMock.Setup(x => x.DeleteAsync($"sms:code:{mobile}")) .Returns(Task.CompletedTask); // Act: Bind mobile (should trigger account merge) var result = await authService.BindMobileAsync(currentUserId, mobile, correctCode); // Assert // 1. Current user should be deleted var currentUserAfter = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); var currentUserDeleted = currentUserAfter == null; // 2. Mobile user should have the current user's openid var mobileUserAfter = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == mobileUserId); var openIdMigrated = mobileUserAfter?.OpenId == currentUserOpenId; // 3. New token should be returned var newTokenReturned = !string.IsNullOrWhiteSpace(result.Token); return currentUserDeleted && openIdMigrated && newTokenReturned; } /// /// Property 14: 直接绑定手机号 /// When a mobile number is not bound to any other user: /// - The current user's mobile should be updated directly /// - No token should be returned (no account merge needed) /// Validates: Requirements 5.4 /// Feature: user-auth-migration, Property 14: 直接绑定手机号 /// [Property(MaxTest = 100)] public async Task DirectMobileBinding() { var dbContext = CreateInMemoryDbContext(); var (authService, _, redisMock) = CreateAuthService(dbContext); var mobile = "138" + Random.Shared.Next(10000000, 99999999).ToString(); var correctCode = "123456"; // Create a user without mobile var user = new User { OpenId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8), Uid = "uid123", Nickname = "TestUser", HeadImg = "https://example.com/avatar.jpg", Status = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await dbContext.Users.AddAsync(user); await dbContext.SaveChangesAsync(); // Setup Redis mock redisMock.Setup(x => x.GetStringAsync($"sms:code:{mobile}")) .ReturnsAsync(correctCode); redisMock.Setup(x => x.DeleteAsync($"sms:code:{mobile}")) .Returns(Task.CompletedTask); // Act: Bind mobile (should be direct binding) var result = await authService.BindMobileAsync(user.Id, mobile, correctCode); // Assert // 1. User's mobile should be updated var userAfter = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == user.Id); var mobileUpdated = userAfter?.Mobile == mobile; // 2. No token should be returned (no merge) var noTokenReturned = string.IsNullOrWhiteSpace(result.Token); // 3. User count should remain the same var userCount = await dbContext.Users.CountAsync(); var userCountCorrect = userCount == 1; return mobileUpdated && noTokenReturned && userCountCorrect; } }