HaniBlindBox/server/HoneyBox/tests/HoneyBox.Tests/Services/AuthServiceBindMobilePropertyTests.cs
2026-01-04 01:47:02 +08:00

272 lines
10 KiB
C#

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;
/// <summary>
/// AuthService手机号绑定属性测试
/// 测试手机号绑定相关的核心属性
/// </summary>
public class AuthServiceBindMobilePropertyTests
{
private HoneyBoxDbContext CreateInMemoryDbContext()
{
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning))
.Options;
return new HoneyBoxDbContext(options);
}
private (AuthService authService, Mock<IWechatService> wechatMock, Mock<IRedisService> redisMock) CreateAuthService(HoneyBoxDbContext dbContext)
{
var mockUserLogger = new Mock<ILogger<UserService>>();
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<ILogger<JwtService>>();
var jwtService = new JwtService(jwtSettings, mockJwtLogger.Object);
var mockWechatService = new Mock<IWechatService>();
var mockIpLocationService = new Mock<IIpLocationService>();
var mockRedisService = new Mock<IRedisService>();
var mockAuthLogger = new Mock<ILogger<AuthService>>();
mockIpLocationService.Setup(x => x.GetLocationAsync(It.IsAny<string>()))
.ReturnsAsync(new IpLocationResult { Success = true, Province = "北京", City = "北京", Adcode = "110000" });
var authService = new AuthService(
dbContext,
userService,
jwtService,
mockWechatService.Object,
mockIpLocationService.Object,
mockRedisService.Object,
mockAuthLogger.Object);
return (authService, mockWechatService, mockRedisService);
}
/// <summary>
/// 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: 手机号绑定验证码验证
/// </summary>
[Property(MaxTest = 100)]
public async Task<bool> 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;
}
/// <summary>
/// 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: 账户合并正确性
/// </summary>
[Property(MaxTest = 100)]
public async Task<bool> 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;
}
/// <summary>
/// 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: 直接绑定手机号
/// </summary>
[Property(MaxTest = 100)]
public async Task<bool> 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;
}
}