修改User模块,同步原始代码
This commit is contained in:
1 parent
4a5fada435
commit
78b0c2bb2e
47 files changed
+2939
-193
No files matched your search
@@ -0,0 +1,90 @@
|
||||
using MAX.Framework.Conventions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
public sealed class ApiRoutePrefixTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(typeof(PrefixRouteTestController), "Read", "api/prefix-tests/Read")]
|
||||
[InlineData(typeof(PrefixRouteTestController), "Absolute", "api/absolute-test")]
|
||||
[InlineData(typeof(PrefixRouteTestController), "ExistingAbsolute", "api/existing-absolute-test")]
|
||||
[InlineData(typeof(ExistingPrefixTestController), "Read", "api/v1/prefix-tests/Read")]
|
||||
[InlineData(typeof(ActionPrefixTestController), "Read", "api/action-prefix-test")]
|
||||
[InlineData(typeof(NonApiPrefixTestController), "Read", "non-api-prefix-test")]
|
||||
public void RoutesAndApiExplorer_UsePrefixOnlyForApiControllers(Type controllerType, string actionName, string expected)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddControllers(options => options.Conventions.Add(new ApiRoutePrefixConvention()))
|
||||
.AddApplicationPart(typeof(PrefixRouteTestController).Assembly);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var action = Assert.Single(provider.GetRequiredService<IActionDescriptorCollectionProvider>()
|
||||
.ActionDescriptors.Items.OfType<ControllerActionDescriptor>(), action =>
|
||||
action.ControllerTypeInfo.AsType() == controllerType && action.ActionName == actionName);
|
||||
Assert.Equal(expected, action.AttributeRouteInfo!.Template);
|
||||
var description = Assert.Single(provider.GetRequiredService<IApiDescriptionGroupCollectionProvider>()
|
||||
.ApiDescriptionGroups.Items.SelectMany(group => group.Items), description => description.ActionDescriptor.Id == action.Id);
|
||||
Assert.Equal(expected, description.RelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("prefix-tests/[action]")]
|
||||
public sealed class PrefixRouteTestController : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public string Read()
|
||||
{
|
||||
return "ok";
|
||||
}
|
||||
|
||||
[HttpPost("~/absolute-test")]
|
||||
public string Absolute()
|
||||
{
|
||||
return "ok";
|
||||
}
|
||||
|
||||
[HttpPost("/api/existing-absolute-test")]
|
||||
public string ExistingAbsolute()
|
||||
{
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/prefix-tests/[action]")]
|
||||
public sealed class ExistingPrefixTestController : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public string Read()
|
||||
{
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
public sealed class ActionPrefixTestController : ControllerBase
|
||||
{
|
||||
[HttpPost("action-prefix-test")]
|
||||
public string Read()
|
||||
{
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable AV0014 // 故意保留非 API 控制器,验证约定的作用范围。
|
||||
[ApiExplorerSettings(IgnoreApi = false)]
|
||||
public sealed class NonApiPrefixTestController : ControllerBase
|
||||
{
|
||||
[HttpPost("non-api-prefix-test")]
|
||||
public string Read()
|
||||
{
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
#pragma warning restore AV0014
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Net;
|
||||
using MAX.Framework.Conventions;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ActionConstraints;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
/// <summary>验证公共默认 GET 约定的真实路由行为及接口文档。</summary>
|
||||
public sealed class DefaultHttpGetTests : IAsyncLifetime
|
||||
{
|
||||
private WebApplication _app = null!;
|
||||
private HttpClient _client = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Services.AddControllers().AddApplicationPart(typeof(DefaultGetTestController).Assembly);
|
||||
_app = builder.Build();
|
||||
_app.MapControllers();
|
||||
await _app.StartAsync();
|
||||
_client = _app.GetTestClient();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Read", "GET", "POST")]
|
||||
[InlineData("Create", "POST", "GET")]
|
||||
[InlineData("Replace", "PUT", "GET")]
|
||||
public async Task UnmarkedAction_DefaultsToGet_AndExplicitMethodsTakePrecedence(string action, string allowed, string rejected)
|
||||
{
|
||||
using var validRequest = new HttpRequestMessage(new HttpMethod(allowed), $"/default-get-tests/{action}");
|
||||
using var validResponse = await _client.SendAsync(validRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, validResponse.StatusCode);
|
||||
using var invalidRequest = new HttpRequestMessage(new HttpMethod(rejected), $"/default-get-tests/{action}");
|
||||
using var invalidResponse = await _client.SendAsync(invalidRequest);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, invalidResponse.StatusCode);
|
||||
Assert.Contains(allowed, invalidResponse.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActionConstraints_EndpointMetadata_AndApiExplorer_Agree()
|
||||
{
|
||||
var action = Assert.Single(_app.Services.GetRequiredService<IActionDescriptorCollectionProvider>()
|
||||
.ActionDescriptors.Items.OfType<ControllerActionDescriptor>(), a =>
|
||||
a.ControllerTypeInfo.AsType() == typeof(DefaultGetTestController) && a.ActionName == "Read");
|
||||
Assert.Equal("GET", Assert.Single(Assert.Single(action.ActionConstraints!.OfType<HttpMethodActionConstraint>()).HttpMethods));
|
||||
Assert.Equal("GET", Assert.Single(Assert.Single(action.EndpointMetadata.OfType<IHttpMethodMetadata>()).HttpMethods));
|
||||
var description = Assert.Single(_app.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>()
|
||||
.ApiDescriptionGroups.Items.SelectMany(g => g.Items), d => d.ActionDescriptor.Id == action.Id);
|
||||
Assert.Equal("GET", description.HttpMethod);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_client?.Dispose();
|
||||
if (_app != null) await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[DefaultHttpGet]
|
||||
[Route("default-get-tests/[action]")]
|
||||
public sealed class DefaultGetTestController : ControllerBase
|
||||
{
|
||||
public string Read() => "read";
|
||||
|
||||
[HttpPost]
|
||||
public string Create() => "created";
|
||||
|
||||
[HttpPut]
|
||||
public string Replace() => "replaced";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# User 模块测试
|
||||
|
||||
## 执行
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj
|
||||
```
|
||||
|
||||
按类别运行:
|
||||
|
||||
```powershell
|
||||
dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj --filter FullyQualifiedName~UserFundsTests
|
||||
dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj --filter FullyQualifiedName~UserHttpTests
|
||||
```
|
||||
|
||||
## 文件与覆盖范围
|
||||
|
||||
| 文件 | 用途与关键用例 |
|
||||
| --- | --- |
|
||||
| `UserTestContext.cs` | 每个用例单独建立临时 SQLite 数据库,构造真实 FreeSql 仓储和服务,模拟当前用户及会话注销记录,运行后清理 |
|
||||
| `UserAuthenticationTests.cs` | 空输入、不存在账号、错误密码、SQL 注入形式输入、明文密码保持不变、哈希随机盐、损坏哈希、修改密码、取消请求 |
|
||||
| `UserManagementTests.cs` | 认证要求、本人/主子账号/管理员访问范围、无密码输出、账户创建默认值、重名拒绝、分组规则、权限提升拦截、资料更新、计费继承、分页、删除限制 |
|
||||
| `UserFundsTests.cs` | 积分和余额充值、累计金额、审计字段、重复请求、双账户积分转移、向自己转移、超扣、非管理员调整、非法金额/类型、流水故障回滚及原请求重试 |
|
||||
| `UserRecordTests.cs` | 账户隔离、操作类型/店铺/订单/时间组合过滤、时间包含边界、分页排序、排除已删除记录、无效日期范围 |
|
||||
| `UserTopUpTests.cs` | 充值备注身份与可见范围、处理权限、处理备注不自动充值、二维码更新或插入、Data URI、损坏数据、管理员限制 |
|
||||
| `UserHttpTests.cs` | TestServer 真实 MVC 请求,12 个默认 POST 操作拒绝 GET/PUT/DELETE,7 个 GET 操作拒绝 POST;未登录 401、JSON 模型验证、有效 POST 创建、认证身份读取、ApiExplorer 与端点方法一致 |
|
||||
|
||||
使用 xUnit,Theory 每组参数分别统计,失败时输出具体测试名称。测试项目已添加到解决方案的 Tests 分组。
|
||||
|
||||
## 隔离与边界
|
||||
|
||||
测试不会读取 `Store.API/appsettings.json`、访问业务 MySQL、调用第三方服务或修改业务数据。每个测试拥有独立数据库,时间过滤采用固定时间,身份通过测试上下文或仅在 TestServer 注册的测试认证方案提供。HTTP 测试接入真实 UnifyResultFilter、GlobalExceptionFilter 及与宿主一致的模型验证响应,验证数据只包装一次,无数据操作、业务异常和参数错误均返回 HTTP 200 的 ApiResult。路由拒绝与测试认证挑战仍保留原始 405/401,此测试宿主未装配全局状态码中间件;测试认证不验证 JWT 签名。
|
||||
|
||||
SQLite 能验证数据持久化和异常回滚,但不能替代 MySQL InnoDB 并发行锁、隔离级别和迁移脚本验证。原有 `UserManagementSmoke` 和 `ShopRenewSmoke` 冒烟入口仍可单独运行。
|
||||
|
||||
User DTO 仅声明 Required。RequestValidatorTests 验证非空规则、错误集合和这一模块约定,同时确认公共工具仍支持其他模块的完整 DataAnnotations。UserServiceValidationTests 验证服务层的长度、范围和 ID 判断;原有金额、精度、格式、日期等用例继续通过服务入口验证。HTTP 测试分别验证 DTO 非空拦截与服务手动参数检查,都按系统约定返回 HTTP 200 和 ValidationError。
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MAX.Framework.Validation;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Dtos;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
/// <summary>验证公共工具,以及 User DTO 仅保留 Required 的模块约定。</summary>
|
||||
public sealed class RequestValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void NullRequest_IsRejectedByBothEntrypoints()
|
||||
{
|
||||
Assert.Equal("request", Assert.Throws<ArgumentNullException>(() => RequestValidator.Validate(null)).ParamName);
|
||||
Assert.Throws<ArgumentNullException>(() => RequestValidator.TryValidate(null, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidDto_PassesWithoutChangingInput()
|
||||
{
|
||||
var request = new UserCreateRequest { UserName = " main ", Password = "password", Notes = "" };
|
||||
RequestValidator.Validate(request);
|
||||
Assert.True(RequestValidator.TryValidate(request, out var errors));
|
||||
Assert.Empty(errors);
|
||||
Assert.Equal(" main ", request.UserName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void RequiredFields_RejectNullEmptyAndWhitespace(string? value)
|
||||
{
|
||||
var request = new UserFundsRequest { UserName = value!, RequestId = "id", Amount = 1m, Operation = "充值" };
|
||||
var error = Assert.Throws<ValidationException>(() => RequestValidator.Validate(request));
|
||||
Assert.Equal("目标账户不能为空", error.Message);
|
||||
Assert.Contains(nameof(UserFundsRequest.UserName), error.ValidationResult!.MemberNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ReturnsAllMissingRequiredFields()
|
||||
{
|
||||
Assert.False(RequestValidator.TryValidate(new UserFundsRequest(), out var errors));
|
||||
Assert.Equal(3, errors.Count);
|
||||
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.UserName)));
|
||||
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.RequestId)));
|
||||
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.Operation)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(UserFundsRequest))]
|
||||
[InlineData(typeof(UserEditRequest))]
|
||||
[InlineData(typeof(UserCreateRequest))]
|
||||
[InlineData(typeof(ChangePasswordRequest))]
|
||||
[InlineData(typeof(UserSearchRequest))]
|
||||
[InlineData(typeof(UserRecordSearchRequest))]
|
||||
[InlineData(typeof(UserTopUpMessageRequest))]
|
||||
[InlineData(typeof(UserTopUpResultRequest))]
|
||||
[InlineData(typeof(UserTopUpQrcodeRequest))]
|
||||
[InlineData(typeof(UserNameRequest))]
|
||||
[InlineData(typeof(UserLoginRequest))]
|
||||
[InlineData(typeof(UserTopUpQrcodeQuery))]
|
||||
[InlineData(typeof(ShopRenewRequest))]
|
||||
public void UserDtos_DeclareOnlyRequiredValidation(Type type)
|
||||
{
|
||||
Assert.False(typeof(IValidatableObject).IsAssignableFrom(type));
|
||||
foreach (var property in type.GetProperties())
|
||||
Assert.All(property.GetCustomAttributes(typeof(ValidationAttribute), true), attribute => Assert.IsType<RequiredAttribute>(attribute));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserDto_DoesNotCheckNonEmptyParameterValues()
|
||||
{
|
||||
// 这些值非空但不合法:DTO 应通过,服务负责拒绝。
|
||||
RequestValidator.Validate(new UserFundsRequest { UserName = new string('u', 21), RequestId = "id", Amount = -1, Operation = "非法操作" });
|
||||
RequestValidator.Validate(new UserTopUpQrcodeRequest { AgentCode = "agent", QrcodeBase64 = "invalid!" });
|
||||
RequestValidator.Validate(new UserRecordSearchRequest { Page = 0, StartTime = new(2025, 2, 1), EndTime = new(2025, 1, 1) });
|
||||
RequestValidator.Validate(new ShopRenewRequest { ShopId = "shop", RenewDuration = 0 });
|
||||
RequestValidator.Validate(new UserCreateRequest { UserName = "user", Password = "123", ShopPointsPerMonth = 1 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameworkValidator_StillSupportsOtherModulesAnnotationsAndCrossFieldRules()
|
||||
{
|
||||
// User 的约定不限制公共工具支持标准 DataAnnotations 的能力。
|
||||
Assert.Throws<ValidationException>(() => RequestValidator.Validate(new OtherModuleRequest { Name = "toolong" }));
|
||||
Assert.False(RequestValidator.TryValidate(new OtherModuleRequest { Name = "ok", Count = 0 }, out _));
|
||||
var request = new OtherModuleRequest { Name = "ok", Count = 2, Start = 2, End = 1 };
|
||||
Assert.Equal("顺序错误", Assert.Throws<ValidationException>(() => RequestValidator.Validate(request)).Message);
|
||||
}
|
||||
|
||||
private sealed class OtherModuleRequest : IValidatableObject
|
||||
{
|
||||
[Required, StringLength(4)] public string Name { get; set; } = "";
|
||||
[Range(1, 10)] public int Count { get; set; } = 1;
|
||||
public int Start { get; set; }
|
||||
public int End { get; set; }
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext context)
|
||||
{
|
||||
if (Start > End) yield return new ValidationResult("顺序错误");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Modules/Store.Users/Store.Users.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FreeSql.Provider.Sqlite" Version="3.5.311" />
|
||||
</ItemGroup>
|
||||
<ItemGroup><Using Include="Xunit" /></ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using Store.Users.Entities;
|
||||
using Store.Users.Services;
|
||||
using MAX.Framework.Commons;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
public sealed class UserAuthenticationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("", "password")]
|
||||
[InlineData(" ", "password")]
|
||||
[InlineData("main", "")]
|
||||
[InlineData("missing", "legacy-password")]
|
||||
[InlineData("main", "wrong")]
|
||||
[InlineData("main' OR 1=1 --", "legacy-password")]
|
||||
public async Task InvalidCredentials_ReturnNullWithoutChangingPassword(string name, string password)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
Assert.Null(await context.Authentication.Authenticate(name, password));
|
||||
Assert.Equal("legacy-password", (await context.Read("main")).Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyPassword_RemainsUnchangedAfterRepeatedLogins()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var info = await context.Authentication.Authenticate(" main ", "legacy-password");
|
||||
Assert.Equal("main", info!.UserName);
|
||||
var user = await context.Read("main");
|
||||
Assert.Equal("legacy-password", user.Password);
|
||||
Assert.True(UserPassword.Verify(user, "legacy-password"));
|
||||
Assert.NotNull(await context.Authentication.Authenticate("main", "legacy-password"));
|
||||
Assert.Equal("legacy-password", (await context.Read("main")).Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HashedPassword_RemainsUnchangedAfterLogin()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
await context.Users.ChangePassword(new() { OldPassword = "legacy-password", NewPassword = "new-password" }, default);
|
||||
var hash = (await context.Read("main")).Password;
|
||||
|
||||
Assert.NotNull(await context.Authentication.Authenticate("main", "new-password"));
|
||||
Assert.Equal(hash, (await context.Read("main")).Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordHashes_AreSalted_AndRejectWrongPassword()
|
||||
{
|
||||
var user = new UserEntity();
|
||||
user.Password = UserPassword.Hash(user, "test-password");
|
||||
Assert.NotEqual(user.Password, UserPassword.Hash(user, "test-password"));
|
||||
Assert.True(UserPassword.Verify(user, "test-password"));
|
||||
Assert.False(UserPassword.Verify(user, "wrong-password"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AQAAAAinvalid!")]
|
||||
[InlineData("AAinvalid!")]
|
||||
[InlineData("")]
|
||||
public void MalformedPasswordHash_IsRejected(string hash) =>
|
||||
Assert.False(UserPassword.Verify(new UserEntity { Password = hash }, "test-password"));
|
||||
|
||||
[Fact]
|
||||
public async Task WrongOldPassword_DoesNotChangePasswordOrSignOut()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.ChangePassword(
|
||||
new() { OldPassword = "wrong", NewPassword = "new-password" }, default));
|
||||
Assert.Equal("原密码错误", error.Message);
|
||||
Assert.Equal("legacy-password", (await context.Read("main")).Password);
|
||||
Assert.Empty(context.Sessions.SignedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_InvalidatesOldPasswordAndSession()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
await context.Users.ChangePassword(new() { OldPassword = "legacy-password", NewPassword = "new-password" }, default);
|
||||
Assert.Contains("main", context.Sessions.SignedOut);
|
||||
Assert.Null(await context.Authentication.Authenticate("main", "legacy-password"));
|
||||
Assert.NotNull(await context.Authentication.Authenticate("main", "new-password"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelledAuthentication_ThrowsBeforeChangingPassword()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
context.Authentication.Authenticate("main", "legacy-password", cancellation.Token));
|
||||
Assert.Equal("legacy-password", (await context.Read("main")).Password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MAX.Framework.Commons;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
public sealed class UserFundsTests
|
||||
{
|
||||
private static UserFundsRequest Request(string operation = "充值", decimal amount = 100m, string target = "main", string id = "request-1") =>
|
||||
new() { UserName = target, RequestId = id, Amount = amount, Operation = operation, Remark = "测试操作" };
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Recharge_UpdatesFundsAndTotals_AndWritesAuditRecord(bool points)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var response = await context.Funds.Change(Request(), points, default);
|
||||
Assert.Equal(100m, response.Value);
|
||||
var user = await context.Read("main");
|
||||
if (points)
|
||||
{
|
||||
Assert.Equal(100m, user.Points);
|
||||
Assert.Equal(100m, user.RechargePointsTotal);
|
||||
var record = Assert.Single(await context.Db.Select<UserPointsRecordEntity>().ToListAsync());
|
||||
Assert.Equal("充值", record.OptionType);
|
||||
Assert.Equal("admin", record.OperatorName);
|
||||
Assert.Equal("管理员", record.OperatorType);
|
||||
Assert.Equal(100m, record.NewPoints);
|
||||
Assert.Equal("user:request-1", record.OutTradeNo);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(100m, user.Balance);
|
||||
Assert.Equal(100m, user.RechargeAmountTotal);
|
||||
var record = Assert.Single(await context.Db.Select<UserPaymentRecordEntity>().ToListAsync());
|
||||
Assert.Equal(1, record.Type);
|
||||
Assert.Equal("admin", record.CreateBy);
|
||||
Assert.Equal(100m, record.Balance);
|
||||
Assert.Equal("user:request-1", record.RefundFlowNo);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RepeatedRequest_IsRejectedWithoutDuplicateFundsOrRecords(bool points)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Funds.Change(Request(), points, default);
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(Request(), points, default));
|
||||
Assert.Contains("已处理", error.Message);
|
||||
var user = await context.Read("main");
|
||||
Assert.Equal(100m, points ? user.Points : user.Balance);
|
||||
Assert.Equal(1, points ? await context.Db.Select<UserPointsRecordEntity>().CountAsync()
|
||||
: await context.Db.Select<UserPaymentRecordEntity>().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transfer_MovesOwnPointsAndCreatesMatchingAuditPair()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Funds.Change(Request(), true, default);
|
||||
context.CurrentUser.UserName = "main";
|
||||
await context.Funds.Change(Request("转移", 30m, "other", "transfer"), true, default);
|
||||
var sender = await context.Read("main");
|
||||
var receiver = await context.Read("other");
|
||||
Assert.Equal(70m, sender.Points);
|
||||
Assert.Equal(30m, receiver.Points);
|
||||
Assert.Equal(100m, sender.RechargePointsTotal);
|
||||
Assert.Equal(30m, receiver.RechargePointsTotal);
|
||||
var records = await context.Db.Select<UserPointsRecordEntity>().Where(r => r.OutTradeNo == "user:transfer").ToListAsync();
|
||||
Assert.Equal(2, records.Count);
|
||||
var debit = Assert.Single(records, r => r.OptionType == "转出");
|
||||
var credit = Assert.Single(records, r => r.OptionType == "转入");
|
||||
Assert.Equal(30m, debit.DeltaPoints);
|
||||
Assert.Equal(debit.DeltaPoints, credit.DeltaPoints);
|
||||
Assert.Equal("other", debit.CounterpartyAccountName);
|
||||
Assert.Equal("main", credit.CounterpartyAccountName);
|
||||
Assert.All(records, r => Assert.Equal("main", r.OperatorName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransferToSelf_IsRejectedWithoutRecords()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(Request("转移"), true, default));
|
||||
Assert.Contains("自己", error.Message);
|
||||
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task InsufficientFunds_DoesNotChangeAccountsOrCreateRecords(bool points)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
if (points) context.CurrentUser.UserName = "other";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(Request(points ? "转移" : "消费"), points, default));
|
||||
Assert.Contains("不足", error.Message);
|
||||
Assert.Equal(0m, (await context.Read("main")).Points);
|
||||
Assert.Equal(0m, (await context.Read("other")).Points);
|
||||
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
|
||||
Assert.Equal(0, await context.Db.Select<UserPaymentRecordEntity>().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BalanceConsumption_DoesNotReduceHistoricalRechargeTotal()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Funds.Change(Request(), false, default);
|
||||
await context.Funds.Change(Request("消费", 25.5m, id: "consume"), false, default);
|
||||
var user = await context.Read("main");
|
||||
Assert.Equal(74.5m, user.Balance);
|
||||
Assert.Equal(100m, user.RechargeAmountTotal);
|
||||
var record = await context.Db.Select<UserPaymentRecordEntity>().Where(r => r.Type == 0).FirstAsync();
|
||||
Assert.Equal(25.5m, record.Amount);
|
||||
Assert.Equal(74.5m, record.Balance);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "充值")]
|
||||
[InlineData(false, "充值")]
|
||||
[InlineData(false, "消费")]
|
||||
public async Task NonAdmin_CannotAdjustFunds(bool points, string operation)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(Request(operation), points, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("0.001")]
|
||||
[InlineData("1000001")]
|
||||
public async Task InvalidAmount_IsRejectedBeforeWriting(string amount)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Funds.Change(Request(amount:
|
||||
decimal.Parse(amount, System.Globalization.CultureInfo.InvariantCulture)), true, default));
|
||||
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "消费")]
|
||||
[InlineData(false, "转移")]
|
||||
public async Task UnsupportedOperation_IsRejected(bool points, string operation)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var error = await Assert.ThrowsAsync<ValidationException>(() => context.Funds.Change(Request(operation), points, default));
|
||||
Assert.Equal("不支持的资金操作类型", error.Message);
|
||||
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
|
||||
Assert.Equal(0, await context.Db.Select<UserPaymentRecordEntity>().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownOperation_IsRejectedByServiceValidation()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var error = await Assert.ThrowsAsync<ValidationException>(() => context.Funds.Change(Request("续费"), true, default));
|
||||
Assert.Equal("不支持的资金操作类型", error.Message);
|
||||
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
|
||||
}
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task AuditWriteFailure_RollsBackAllChanges_AndAllowsRetryWithSameRequest(bool points)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
// 积分在第二条转账流水故障,余额在充值流水故障,均应回滚先前账户更新。
|
||||
if (points)
|
||||
{
|
||||
await context.Funds.Change(Request(), true, default);
|
||||
context.CurrentUser.UserName = "main";
|
||||
}
|
||||
var trigger = points
|
||||
? "CREATE TRIGGER fail_audit BEFORE INSERT ON points WHEN NEW.option_type = '转入' BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END"
|
||||
: "CREATE TRIGGER fail_audit BEFORE INSERT ON payments BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END";
|
||||
await context.Db.Ado.ExecuteNonQueryAsync(trigger);
|
||||
var request = Request(points ? "转移" : "充值", 20m, "other", "retry");
|
||||
var error = await Assert.ThrowsAnyAsync<Exception>(() => context.Funds.Change(request, points, default));
|
||||
Assert.Contains("forced audit failure", error.Message);
|
||||
Assert.Equal(0m, points ? (await context.Read("other")).Points : (await context.Read("other")).Balance);
|
||||
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().Where(r => r.OutTradeNo == "user:retry").CountAsync());
|
||||
Assert.Equal(0, await context.Db.Select<UserPaymentRecordEntity>().CountAsync());
|
||||
if (points) Assert.Equal(100m, (await context.Read("main")).Points);
|
||||
await context.Db.Ado.ExecuteNonQueryAsync("DROP TRIGGER fail_audit");
|
||||
Assert.Equal(20m, (await context.Funds.Change(request, points, default)).Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
using Asp.Versioning;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Dtos;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MAX.Framework.DependencyInjection;
|
||||
using MAX.Framework.Security;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.ActionConstraints;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Controllers;
|
||||
using Store.Users.Services;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
/// <summary>真实 MVC 请求管线测试:仅替换认证方案和数据库,不启动业务宿主或访问外部系统。</summary>
|
||||
public sealed class UserHttpTests : IAsyncLifetime
|
||||
{
|
||||
private readonly UserTestContext _context = new();
|
||||
private WebApplication _app = null!;
|
||||
private HttpClient _client = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Logging.ClearProviders();
|
||||
// 与宿主一致:正常数据自动包装,显式 ApiResult 不重复包装,业务异常及模型验证返回 HTTP 200。
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<GlobalExceptionFilter>();
|
||||
options.Filters.Add<UnifyResultFilter>();
|
||||
}).AddApplicationPart(typeof(UserController).Assembly).ConfigureApiBehaviorOptions(options =>
|
||||
{
|
||||
options.InvalidModelStateResponseFactory = context => new ObjectResult(ApiResult.Fail(
|
||||
string.Join("; ", context.ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)),
|
||||
ResultCode.ValidationError)) { StatusCode = 200 };
|
||||
});
|
||||
builder.Services.AddApiVersioning(options =>
|
||||
{
|
||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
options.ApiVersionReader = new UrlSegmentApiVersionReader();
|
||||
}).AddMvc();
|
||||
builder.Services.AddAuthentication("Test").AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>("Test", _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
builder.Services.AddSingleton(_context.Db);
|
||||
builder.Services.AddSingleton(_context.Query);
|
||||
builder.Services.AddSingleton<IOnlineUserStore>(_context.Sessions);
|
||||
builder.Services.AddTransient<UserManagementService>();
|
||||
builder.Services.AddTransient<UserFundsService>();
|
||||
builder.Services.AddMediatRAttributedBehaviors(typeof(UserController).Assembly);
|
||||
_app = builder.Build();
|
||||
_app.UseAuthentication();
|
||||
_app.UseAuthorization();
|
||||
_app.MapControllers();
|
||||
await _app.StartAsync();
|
||||
_client = _app.GetTestClient();
|
||||
_client.DefaultRequestHeaders.Add("X-Test-User", "admin");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AddUser")]
|
||||
[InlineData("AddSubUser")]
|
||||
[InlineData("UpdateUser")]
|
||||
[InlineData("UpdateSubUser")]
|
||||
[InlineData("DelUser")]
|
||||
[InlineData("ChangePassword")]
|
||||
[InlineData("UpdateUserPoints")]
|
||||
[InlineData("UpdateUserBalance")]
|
||||
[InlineData("AddUserTopUpMsg")]
|
||||
[InlineData("UpdateUserTopUpMsg")]
|
||||
[InlineData("UpdateTopUpQrcode")]
|
||||
[InlineData("ShopRenew")]
|
||||
public async Task UnmarkedActions_RejectGetPutAndDelete(string action)
|
||||
{
|
||||
foreach (var method in new[] { HttpMethod.Get, HttpMethod.Put, HttpMethod.Delete })
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, $"/v1/user/{action}");
|
||||
using var response = await _client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Contains("POST", response.Content.Headers.Allow);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GetUser")]
|
||||
[InlineData("GetAllUser")]
|
||||
[InlineData("GetSubUser")]
|
||||
[InlineData("GetUserPoints")]
|
||||
[InlineData("GetUserBalance")]
|
||||
[InlineData("GetUserTopUpMsg")]
|
||||
[InlineData("GetTopUpQrcode")]
|
||||
public async Task QueryActions_RejectGet(string action)
|
||||
{
|
||||
using var response = await _client.GetAsync($"/v1/user/{action}");
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
|
||||
Assert.Contains("POST", response.Content.Headers.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnauthenticatedRequests_AreChallengedBeforeReadingOrWriting()
|
||||
{
|
||||
_client.DefaultRequestHeaders.Remove("X-Test-User");
|
||||
using var read = await _client.PostAsJsonAsync("/v1/user/GetUser", new { });
|
||||
using var write = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName = "created", password = "new-password" });
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, read.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, write.StatusCode);
|
||||
Assert.Null(await _context.Read("created"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultPost_CreatesAccountFromJsonBody_WithoutExposingPassword()
|
||||
{
|
||||
using var response = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName = "created", password = "new-password" });
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("password", content, StringComparison.OrdinalIgnoreCase);
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult<UserInfo>>();
|
||||
Assert.True(result!.Success);
|
||||
var info = result.Data;
|
||||
Assert.Equal("created", info!.UserName);
|
||||
Assert.NotNull(await _context.Read("created"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_ReadsCurrentUserFromRequestIdentity()
|
||||
{
|
||||
_client.DefaultRequestHeaders.Remove("X-Test-User");
|
||||
_client.DefaultRequestHeaders.Add("X-Test-User", "main");
|
||||
using var response = await _client.PostAsJsonAsync("/v1/user/GetUser", new { });
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult<UserInfo>>();
|
||||
Assert.True(result!.Success);
|
||||
var info = result.Data;
|
||||
Assert.Equal("main", info!.UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidPasswordLength_IsRejectedByServiceBeforeCreatingAccount()
|
||||
{
|
||||
// 遵循宿主验证约定:HTTP 200 + 业务错误码,不返回默认的 HTTP 400。
|
||||
using var response = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName = "created", password = "123" });
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult>();
|
||||
Assert.Equal(ResultCode.ValidationError, result!.Code);
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(await _context.Read("created"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "password")]
|
||||
[InlineData("", "password")]
|
||||
[InlineData("created", " ")]
|
||||
public async Task MissingRequiredInput_IsRejectedByDtoBeforeCreatingAccount(string? userName, string password)
|
||||
{
|
||||
using var response = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName, password });
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult>();
|
||||
Assert.Equal(ResultCode.ValidationError, result!.Code);
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(await _context.Read("created"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DelUser")]
|
||||
[InlineData("ChangePassword")]
|
||||
[InlineData("UpdateUserTopUpMsg")]
|
||||
[InlineData("UpdateTopUpQrcode")]
|
||||
public async Task CommandsWithoutData_ReturnHttp200AndSingleApiResult(string action)
|
||||
{
|
||||
string url = $"/v1/user/{action}";
|
||||
object body = new { };
|
||||
switch (action)
|
||||
{
|
||||
case "DelUser":
|
||||
url += $"?id={_context.Other.Id}";
|
||||
break;
|
||||
case "ChangePassword":
|
||||
body = new { oldPassword = "legacy-password", newPassword = "new-password" };
|
||||
break;
|
||||
case "UpdateUserTopUpMsg":
|
||||
var id = await _context.Users.AddTopUpMessage(new() { Message = "测试充值备注" }, default);
|
||||
body = new { id, result = "已处理" };
|
||||
break;
|
||||
case "UpdateTopUpQrcode":
|
||||
body = new { agentCode = "agent", qrcodeBase64 = "aGVsbG8=" };
|
||||
break;
|
||||
}
|
||||
using var response = await _client.PostAsJsonAsync(url, body);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var result = document.RootElement;
|
||||
Assert.Equal(ResultCode.Success, result.GetProperty("code").GetInt32());
|
||||
Assert.True(result.GetProperty("success").GetBoolean());
|
||||
Assert.Equal("操作成功", result.GetProperty("msg").GetString());
|
||||
// data 必须为 null,不能变成包含另一层 code/data 的 ApiResult。
|
||||
Assert.Equal(JsonValueKind.Null, result.GetProperty("data").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BusinessFailure_ReturnsHttp200WithFailureApiResult()
|
||||
{
|
||||
using var response = await _client.PostAsJsonAsync("/v1/user/ChangePassword",
|
||||
new { oldPassword = "wrong-password", newPassword = "new-password" });
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult>();
|
||||
Assert.Equal(ResultCode.BusinessError, result!.Code);
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("原密码错误", result.Msg);
|
||||
Assert.Null(result.Data);
|
||||
Assert.Empty(_context.Sessions.SignedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidQrcode_IsRejectedByServiceWithValidationApiResult()
|
||||
{
|
||||
using var response = await _client.PostAsJsonAsync("/v1/user/UpdateTopUpQrcode",
|
||||
new { agentCode = "agent", qrcodeBase64 = "invalid!" });
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult>();
|
||||
Assert.Equal(ResultCode.ValidationError, result!.Code);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Base64", result.Msg);
|
||||
Assert.Equal(0, await _context.Db.Select<Store.Users.Entities.UserTopUpQrcodeEntity>().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiExplorerAndEndpointMetadata_UsePostForEveryAction()
|
||||
{
|
||||
var descriptors = _app.Services.GetRequiredService<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
|
||||
.OfType<ControllerActionDescriptor>().Where(a => a.ControllerTypeInfo.AsType() == typeof(UserController)).ToArray();
|
||||
var descriptions = _app.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>().ApiDescriptionGroups.Items
|
||||
.SelectMany(g => g.Items).Where(d => d.ActionDescriptor is ControllerActionDescriptor a
|
||||
&& a.ControllerTypeInfo.AsType() == typeof(UserController)).ToArray();
|
||||
Assert.Equal(19, descriptors.Length);
|
||||
Assert.Equal(descriptors.Length, descriptions.Length);
|
||||
foreach (var action in descriptors)
|
||||
{
|
||||
var expected = "POST";
|
||||
Assert.Equal(expected, Assert.Single(Assert.Single(action.ActionConstraints!.OfType<HttpMethodActionConstraint>()).HttpMethods));
|
||||
Assert.Equal(expected, Assert.Single(Assert.Single(action.EndpointMetadata.OfType<IHttpMethodMetadata>()).HttpMethods));
|
||||
Assert.Equal(expected, Assert.Single(descriptions, d => d.ActionDescriptor.Id == action.Id).HttpMethod);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_client?.Dispose();
|
||||
if (_app != null) await _app.DisposeAsync();
|
||||
_context.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>仅在测试宿主注册:请求头模拟已验证身份,生产认证配置不会引用此类。</summary>
|
||||
internal sealed class TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("X-Test-User", out var name)) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.UserName, name.ToString())], Scheme.Name));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using MAX.Framework.Commons;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
using YourNamespace;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
public sealed class UserManagementTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, "main")]
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, "missing")]
|
||||
public async Task MissingIdentity_IsRejected(bool authenticated, string? name)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.IsAuthenticated = authenticated;
|
||||
context.CurrentUser.UserName = name;
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => context.Users.GetUser(null, default));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("main", "main")]
|
||||
[InlineData("main", "child")]
|
||||
[InlineData("admin", "other")]
|
||||
[InlineData("child", "child")]
|
||||
public async Task AuthorizedAccount_CanBeRead(string actor, string target)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = actor;
|
||||
var info = await context.Users.GetUser(target, default);
|
||||
Assert.Equal(target, info.UserName);
|
||||
Assert.Null(info.Password);
|
||||
Assert.DoesNotContain("password", JsonSerializer.Serialize(info), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("main", "other")]
|
||||
[InlineData("child", "main")]
|
||||
[InlineData("other", "child")]
|
||||
public async Task UnrelatedAccount_IsForbidden(string actor, string target)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = actor;
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.GetUser(target, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMainAccount_InitializesDefaultsAndHashesPassword()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var info = await context.Users.Create(new() { UserName = "created", Password = "new-password" }, false, default);
|
||||
var stored = await context.Read("created");
|
||||
Assert.True(info.IsMainUser);
|
||||
Assert.Equal((byte)3, info.GroupId);
|
||||
Assert.Equal(30, info.ShopPointsPerMonth);
|
||||
Assert.Equal(0, info.Points);
|
||||
Assert.Equal(0m, info.Balance);
|
||||
Assert.NotEqual("new-password", stored.Password);
|
||||
Assert.NotNull(stored.AddTime);
|
||||
Assert.Null(info.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateUsername_IsRejectedWithoutCreatingAnAccount()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var count = await context.Db.Select<UserEntity>().CountAsync();
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Create(
|
||||
new() { UserName = " main ", Password = "new-password" }, false, default));
|
||||
Assert.Equal("用户名已存在", error.Message);
|
||||
Assert.Equal(count, await context.Db.Select<UserEntity>().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonAdmin_CannotCreateMainAccount()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Create(
|
||||
new() { UserName = "created", Password = "new-password" }, false, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
[InlineData(8)]
|
||||
[InlineData(9)]
|
||||
[InlineData(10)]
|
||||
[InlineData(11)]
|
||||
public async Task MainAccount_CreatesChildWithServerAssignedParent(byte groupId)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var info = await context.Users.Create(new()
|
||||
{
|
||||
UserName = "created", Password = "new-password", GroupId = groupId, Authority = "订单"
|
||||
}, true, default);
|
||||
Assert.Equal("main", info.ParentUserName);
|
||||
Assert.Equal(groupId, info.GroupId);
|
||||
Assert.Equal((short)0, info.SaLevel);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(255)]
|
||||
public async Task ChildGroup_MustBeOneOfSupportedGroups(byte groupId)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Create(new()
|
||||
{
|
||||
UserName = "created", Password = "new-password", GroupId = groupId
|
||||
}, true, default));
|
||||
Assert.False(await context.Db.Select<UserEntity>().Where(u => u.UserName == "created").AnyAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainGroup_CannotBeUsedForChildAccount()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Create(new()
|
||||
{
|
||||
UserName = "created", Password = "new-password", GroupId = 3
|
||||
}, true, default));
|
||||
Assert.Contains("子账号分组", error.Message);
|
||||
}
|
||||
[Fact]
|
||||
public async Task ChildAccount_CannotCreateAnotherChild()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "child";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Create(
|
||||
new() { UserName = "created", Password = "new-password" }, true, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainAccount_CannotGrantAdditionalAuthorityOrAdministratorLevel()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
foreach (var request in new UserEditRequest[]
|
||||
{
|
||||
new() { Authority = "不存在的权限" }, new() { SaLevel = 1 }, new() { ShopPointsPerMonth = 30 }
|
||||
})
|
||||
{
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Update(context.Child.Id, request, true, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
}
|
||||
Assert.Equal((short)0, (await context.Read("child")).SaLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_PreservesNullFields_ClearsEmptyFields_AndKeepsFunds()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Db.Update<UserEntity>().Set(u => u.Notes, "old note").Set(u => u.Mobile, "13800000000")
|
||||
.Set(u => u.Points, 42m).Set(u => u.Balance, 11m).Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
|
||||
var info = await context.Users.Update(context.Main.Id, new() { Notes = "", NickName = " New Name " }, false, default);
|
||||
Assert.Equal("", info.Notes);
|
||||
Assert.Equal("13800000000", info.Mobile);
|
||||
Assert.Equal("New Name", info.NickName);
|
||||
Assert.Equal(42m, info.Points);
|
||||
Assert.Equal(11m, info.Balance);
|
||||
Assert.Contains("main", context.Sessions.SignedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Child_InheritsOemFeesAndPointsAuthorityFromParent()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Db.Update<UserEntity>().Set(u => u.OemId, "brand").Set(u => u.PostagePerOrder, 1m)
|
||||
.Set(u => u.RefundPostagePerOrder, 1m).Set(u => u.GiftOemAmountPerOrder, -1m)
|
||||
.Set(u => u.SyncLogisticsPerOrder, 0.5m).Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
|
||||
var info = await context.Query.GetUser("child");
|
||||
Assert.Equal("brand", info.OemId);
|
||||
Assert.Equal(2.2m, info.PostagePerOrder);
|
||||
Assert.Equal(2.5m, info.RefundPostagePerOrder);
|
||||
Assert.Equal(0m, info.GiftOemAmountPerOrder);
|
||||
Assert.Equal(0.5m, info.SyncLogisticsPerOrder);
|
||||
await context.Db.Update<UserEntity>().Set(u => u.Authority, "订单").Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
|
||||
Assert.DoesNotContain("积分用户", (await context.Query.GetUser("child")).Authority);
|
||||
// 有效查询结果不能把继承值写回子账号原始配置。
|
||||
Assert.Equal("", (await context.Read("child")).OemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_FiltersAndPaginatesWithoutLeakingOtherChildren()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.Seed("child-two", parent: "main");
|
||||
context.Seed("foreign-child", parent: "other");
|
||||
context.CurrentUser.UserName = "main";
|
||||
var page = await context.Users.Search(new() { Keyword = "child", GroupId = 4, PageSize = 1 }, true, default);
|
||||
Assert.Equal(2, page.Total);
|
||||
Assert.Equal("child-two", Assert.Single(page.Items).UserName);
|
||||
var second = await context.Users.Search(new() { Page = 2, PageSize = 1 }, true, default);
|
||||
Assert.Equal("child", Assert.Single(second.Items).UserName);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Search(new(), false, default))).Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 50)]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(1, 201)]
|
||||
public async Task InvalidPagination_IsRejected(int page, int pageSize)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Search(new() { Page = page, PageSize = pageSize }, false, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyAccount_CanBeDeleted_AndSessionIsInvalidated()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Users.Delete(context.Other.Id, default);
|
||||
Assert.Null(await context.Read("other"));
|
||||
Assert.Contains("other", context.Sessions.SignedOut);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("self")]
|
||||
[InlineData("points")]
|
||||
[InlineData("balance")]
|
||||
[InlineData("children")]
|
||||
[InlineData("shops")]
|
||||
public async Task AccountWithBlockingCondition_CannotBeDeleted(string condition)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var target = context.Other;
|
||||
if (condition == "self") target = context.Admin;
|
||||
if (condition == "children") target = context.Main;
|
||||
if (condition == "points") await context.Db.Update<UserEntity>().Set(u => u.Points, 1m).Where(u => u.Id == target.Id).ExecuteAffrowsAsync();
|
||||
if (condition == "balance") await context.Db.Update<UserEntity>().Set(u => u.Balance, 1m).Where(u => u.Id == target.Id).ExecuteAffrowsAsync();
|
||||
if (condition == "shops") await context.Db.Insert(new ShopEntity { ShopId = "shop", UserName = target.UserName }).ExecuteAffrowsAsync();
|
||||
await Assert.ThrowsAsync<AppException>(() => context.Users.Delete(target.Id, default));
|
||||
Assert.NotNull(await context.Read(target.UserName));
|
||||
Assert.Empty(context.Sessions.SignedOut);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MAX.Framework.Commons;
|
||||
using Store.Users.Entities;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
public sealed class UserRecordTests
|
||||
{
|
||||
private static readonly DateTime Time = new(2025, 1, 15, 12, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public async Task PointsQuery_AppliesAccountOperationShopAndInclusiveTimeRange()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Db.Insert(new[]
|
||||
{
|
||||
Points("main", "充值", "shop-1", Time),
|
||||
Points("main", "充值", "shop-2", Time),
|
||||
Points("main", "消费", "shop-1", Time),
|
||||
Points("main", "充值", "shop-1", Time.AddSeconds(-1)),
|
||||
Points("other", "充值", "shop-1", Time)
|
||||
}).ExecuteAffrowsAsync();
|
||||
var page = await context.Users.Points("main", new()
|
||||
{
|
||||
Operation = "充值", ShopId = "shop-1", StartTime = Time, EndTime = Time
|
||||
}, default);
|
||||
var record = Assert.Single(page.Items);
|
||||
Assert.Equal(1, page.Total);
|
||||
Assert.Equal("main", record.UserName);
|
||||
Assert.Equal(Time, record.AddTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PointsQuery_PaginatesWithStableDescendingIdOrder()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Db.Insert(new[]
|
||||
{
|
||||
Points("main", "充值", "shop-1", Time),
|
||||
Points("main", "充值", "shop-2", Time),
|
||||
Points("main", "充值", "shop-3", Time)
|
||||
}).ExecuteAffrowsAsync();
|
||||
var page = await context.Users.Points("main", new() { Page = 2, PageSize = 1 }, default);
|
||||
Assert.Equal(3, page.Total);
|
||||
Assert.Equal("shop-2", Assert.Single(page.Items).ShopId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BalanceQuery_FiltersTypeOrderAndTime_AndExcludesDeletedRecords()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Db.Insert(new[]
|
||||
{
|
||||
Payment("main", 1, "order", Time),
|
||||
Payment("main", 0, "order", Time),
|
||||
Payment("main", 1, "different", Time),
|
||||
Payment("main", 1, "order", Time.AddSeconds(1)),
|
||||
Payment("other", 1, "order", Time),
|
||||
Payment("main", 1, "order", Time, deleted: true)
|
||||
}).ExecuteAffrowsAsync();
|
||||
var page = await context.Users.Balance("main", new()
|
||||
{
|
||||
PaymentType = 1, OrderId = "order", StartTime = Time, EndTime = Time
|
||||
}, default);
|
||||
var record = Assert.Single(page.Items);
|
||||
Assert.Equal(1, page.Total);
|
||||
Assert.Equal("main", record.UserName);
|
||||
Assert.Equal(0, record.IsDeleted);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task OrdinaryUser_CannotQuerySomeoneElsesFundsHistory(bool points)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var error = await Assert.ThrowsAsync<AppException>(async () =>
|
||||
{
|
||||
if (points) await context.Users.Points("other", new(), default);
|
||||
else await context.Users.Balance("other", new(), default);
|
||||
});
|
||||
Assert.Equal(403, error.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task ReversedDateRange_IsRejected(bool points)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await Assert.ThrowsAsync<ValidationException>(async () =>
|
||||
{
|
||||
var request = new Store.Users.Contracts.Dtos.UserRecordSearchRequest { StartTime = Time, EndTime = Time.AddSeconds(-1) };
|
||||
if (points) await context.Users.Points("main", request, default);
|
||||
else await context.Users.Balance("main", request, default);
|
||||
});
|
||||
}
|
||||
|
||||
private static UserPointsRecordEntity Points(string user, string operation, string shop, DateTime time) => new()
|
||||
{
|
||||
UserName = user, UserNick = user, OptionType = operation, OperatorType = "管理员", OperatorName = "admin",
|
||||
DeltaPoints = 10m, NewPoints = 10m, ShopId = shop, AddTime = time
|
||||
};
|
||||
|
||||
private static UserPaymentRecordEntity Payment(string user, int type, string order, DateTime time, bool deleted = false) => new()
|
||||
{
|
||||
UserName = user, Type = type, Amount = 10m, Balance = 10m, OrderId = order, CreateTime = time,
|
||||
CreateBy = "admin", UpdateBy = "", PlatformCode = "", Remark = "", CounterpartyAccountName = "",
|
||||
RefundFlowNo = "", IsDeleted = deleted ? 1 : 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
/// <summary>确认从 DTO 移出的长度和范围规则仍在服务写入前生效。</summary>
|
||||
public sealed class UserServiceValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public async Task NonPositiveId_IsRejectedByService(int id)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Delete(id, default));
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(id, new(), false, default));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nickname")]
|
||||
[InlineData("mobile")]
|
||||
[InlineData("company")]
|
||||
[InlineData("notes")]
|
||||
[InlineData("card")]
|
||||
[InlineData("authority")]
|
||||
[InlineData("forbidden")]
|
||||
[InlineData("oem")]
|
||||
[InlineData("group")]
|
||||
[InlineData("level")]
|
||||
[InlineData("monthly")]
|
||||
[InlineData("postage")]
|
||||
[InlineData("refund")]
|
||||
[InlineData("gift")]
|
||||
[InlineData("logistics")]
|
||||
public async Task InvalidEdit_IsRejectedBeforePersistence(string field)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var request = new UserEditRequest();
|
||||
switch (field)
|
||||
{
|
||||
case "nickname": request.NickName = new string('x', 51); break;
|
||||
case "mobile": request.Mobile = new string('x', 31); break;
|
||||
case "company": request.CompanyName = new string('x', 101); break;
|
||||
case "notes": request.Notes = new string('x', 256); break;
|
||||
case "card": request.CardId = new string('x', 51); break;
|
||||
case "authority": request.Authority = new string('x', 1001); break;
|
||||
case "forbidden": request.ForbiddenPage = new string('x', 1001); break;
|
||||
case "oem": request.OemId = new string('x', 51); break;
|
||||
case "group": request.GroupId = 0; break;
|
||||
case "level": request.SaLevel = 2; break;
|
||||
case "monthly": request.ShopPointsPerMonth = 29; break;
|
||||
case "postage": request.PostagePerOrder = 2.1m; break;
|
||||
case "refund": request.RefundPostagePerOrder = 2.4m; break;
|
||||
case "gift": request.GiftOemAmountPerOrder = -1m; break;
|
||||
case "logistics": request.SyncLogisticsPerOrder = -1m; break;
|
||||
}
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(context.Main.Id, request, false, default));
|
||||
Assert.Empty(context.Sessions.SignedOut);
|
||||
Assert.Equal("main", (await context.Read("main")).NickName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("username")]
|
||||
[InlineData("password-short")]
|
||||
[InlineData("password-long")]
|
||||
public async Task InvalidCreateLength_IsRejectedByService(string field)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var request = new UserCreateRequest { UserName = "created", Password = "password" };
|
||||
if (field == "username") request.UserName = new string('u', 21);
|
||||
if (field == "password-short") request.Password = "123";
|
||||
if (field == "password-long") request.Password = new string('p', 129);
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Create(request, false, default));
|
||||
Assert.Null(await context.Read(request.UserName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using FreeSql;
|
||||
using MAX.Framework.Security;
|
||||
using Store.Users.Contracts;
|
||||
using Store.Users.Entities;
|
||||
using Store.Users.Services;
|
||||
using System.Security.Claims;
|
||||
using YourNamespace;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
/// <summary>每个测试独享数据库和身份上下文,不读取宿主连接配置,可并行运行。</summary>
|
||||
internal sealed class UserTestContext : IDisposable
|
||||
{
|
||||
private readonly string _path = Path.Combine(Path.GetTempPath(), $"store-user-test-{Guid.NewGuid():N}.db");
|
||||
public IFreeSql Db { get; }
|
||||
public TestCurrentUser CurrentUser { get; } = new();
|
||||
public TestOnlineUsers Sessions { get; } = new();
|
||||
public IUserQueryService Query { get; }
|
||||
public IUserAuthenticationService Authentication { get; }
|
||||
public UserManagementService Users { get; }
|
||||
public UserFundsService Funds { get; }
|
||||
public UserEntity Admin { get; }
|
||||
public UserEntity Main { get; }
|
||||
public UserEntity Other { get; }
|
||||
public UserEntity Child { get; }
|
||||
|
||||
public UserTestContext()
|
||||
{
|
||||
Db = new FreeSqlBuilder()
|
||||
.UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute,
|
||||
FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi)
|
||||
.UseConnectionString(DataType.Sqlite, $"Data Source={_path};Pooling=False")
|
||||
.Build();
|
||||
// 覆盖业务库 schema,只在临时库建立实体测试表。
|
||||
Db.CodeFirst.ConfigEntity<UserEntity>(e => e.Name("users"));
|
||||
Db.CodeFirst.ConfigEntity<UserPointsRecordEntity>(e => e.Name("points"));
|
||||
Db.CodeFirst.ConfigEntity<UserPaymentRecordEntity>(e => e.Name("payments"));
|
||||
Db.CodeFirst.ConfigEntity<ShopEntity>(e => e.Name("shops"));
|
||||
Db.CodeFirst.ConfigEntity<OEMEntity>(e => e.Name("oems"));
|
||||
Db.CodeFirst.ConfigEntity<UserTopUpMessageEntity>(e => e.Name("messages"));
|
||||
Db.CodeFirst.ConfigEntity<UserTopUpQrcodeEntity>(e => e.Name("qrcodes"));
|
||||
Db.CodeFirst.SyncStructure(typeof(UserEntity), typeof(UserPointsRecordEntity), typeof(UserPaymentRecordEntity),
|
||||
typeof(ShopEntity), typeof(OEMEntity), typeof(UserTopUpMessageEntity), typeof(UserTopUpQrcodeEntity));
|
||||
// 模拟部署脚本提供的唯一索引。
|
||||
Db.Ado.ExecuteNonQuery("CREATE UNIQUE INDEX ux_user_name ON users(user_name)");
|
||||
Admin = Seed("admin", 1);
|
||||
Main = Seed("main");
|
||||
Other = Seed("other");
|
||||
Child = Seed("child", parent: Main.UserName);
|
||||
CurrentUser.UserName = Admin.UserName;
|
||||
|
||||
// 生产实现为 internal;通过反射构造真实仓储和查询服务,无需放宽业务代码可见性。
|
||||
var assembly = typeof(UserManagementService).Assembly;
|
||||
var repository = Activator.CreateInstance(assembly.GetType("Store.Users.Repositories.UserRepository", true)!, Db)!;
|
||||
Query = (IUserQueryService)Activator.CreateInstance(assembly.GetType("Store.Users.Services.UserQueryService", true)!, repository)!;
|
||||
Authentication = (IUserAuthenticationService)Activator.CreateInstance(
|
||||
assembly.GetType("Store.Users.Services.UserAuthenticationService", true)!, Db, Query)!;
|
||||
Users = new(Db, CurrentUser, Query, Sessions);
|
||||
Funds = new(Db, CurrentUser);
|
||||
}
|
||||
|
||||
public UserEntity Seed(string name, short level = 0, string parent = "")
|
||||
{
|
||||
var user = new UserEntity
|
||||
{
|
||||
UserName = name, NickName = name, Password = "legacy-password", ParentUserName = parent,
|
||||
GroupId = string.IsNullOrEmpty(parent) ? (byte)3 : (byte)4, SaLevel = level,
|
||||
Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "积分用户,订单",
|
||||
ForbiddenPage = "", OemId = "", Balance = 0, Points = 0, ShopPointsPerMonth = 30,
|
||||
PostagePerOrder = 1m, RefundPostagePerOrder = 1m
|
||||
};
|
||||
user.Id = checked((int)Db.Insert(user).ExecuteIdentity());
|
||||
return user;
|
||||
}
|
||||
|
||||
public Task<UserEntity> Read(string name) => Db.Select<UserEntity>().Where(u => u.UserName == name).FirstAsync();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Db.Dispose();
|
||||
if (File.Exists(_path)) File.Delete(_path);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestCurrentUser : ICurrentUser
|
||||
{
|
||||
public bool IsAuthenticated { get; set; } = true;
|
||||
public string? UserName { get; set; }
|
||||
public string? NickName => UserName;
|
||||
public IEnumerable<Claim> Claims => [];
|
||||
public string? GetClaimValue(string claimType) => null;
|
||||
public T? GetClaimValue<T>(string claimType) => default;
|
||||
}
|
||||
|
||||
internal sealed class TestOnlineUsers : IOnlineUserStore
|
||||
{
|
||||
public HashSet<string> SignedOut { get; } = [];
|
||||
public void SignOut(string userName) => SignedOut.Add(userName);
|
||||
public void SignIn(OnlineUserInfo info) { }
|
||||
public bool IsTokenActive(string userName, string jti) => false;
|
||||
public IReadOnlyList<OnlineUserInfo> GetOnlineUsers() => [];
|
||||
public int Count => 0;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MAX.Framework.Commons;
|
||||
using Store.Users.Entities;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
public sealed class UserTopUpTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Message_UsesCurrentIdentity_AndIsVisibleOnlyToOwnerOrAdmin()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var id = await context.Users.AddTopUpMessage(new() { Message = " 充值待核实 " }, default);
|
||||
var message = Assert.Single((await context.Users.TopUpMessages(new(), default)).Items);
|
||||
Assert.Equal(id, message.Id);
|
||||
Assert.Equal("main", message.UserName);
|
||||
Assert.Equal("main", message.NickName);
|
||||
Assert.Equal("充值待核实", message.Message);
|
||||
context.CurrentUser.UserName = "other";
|
||||
Assert.Empty((await context.Users.TopUpMessages(new(), default)).Items);
|
||||
context.CurrentUser.UserName = "admin";
|
||||
Assert.Single((await context.Users.TopUpMessages(new(), default)).Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingMessage_RequiresAdmin_AndDoesNotRechargeFunds()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var id = await context.Users.AddTopUpMessage(new() { Message = "充值待核实" }, default);
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.UpdateTopUpMessage(new() { Id = id, Result = "已核实" }, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
context.CurrentUser.UserName = "admin";
|
||||
await context.Users.UpdateTopUpMessage(new() { Id = id, Result = "已核实" }, default);
|
||||
Assert.Equal("已核实", (await context.Users.TopUpMessages(new(), default)).Items.Single().Result);
|
||||
Assert.Equal(0m, (await context.Read("main")).Points);
|
||||
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingMessage_CannotBeProcessed()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.UpdateTopUpMessage(new() { Id = 9999, Result = "已核实" }, default));
|
||||
Assert.Equal("充值备注不存在", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Qrcode_UpsertsByAgentCode_AndSupportsDataUri()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "aGVsbG8=" }, default);
|
||||
const string data = "data:image/png;base64,d29ybGQ=";
|
||||
await context.Users.UpdateTopUpQrcode(new() { AgentCode = " agent ", QrcodeBase64 = data }, default);
|
||||
Assert.Equal(data, await context.Users.GetTopUpQrcode("agent", default));
|
||||
Assert.Equal(1, await context.Db.Select<UserTopUpQrcodeEntity>().CountAsync());
|
||||
Assert.Null(await context.Users.GetTopUpQrcode("missing", default));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid!")]
|
||||
[InlineData("data:image/png,abc")]
|
||||
[InlineData("data:image/png;base64,")]
|
||||
public async Task InvalidQrcode_DoesNotOverwriteStoredValue(string invalid)
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
await context.Users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "aGVsbG8=" }, default);
|
||||
await Assert.ThrowsAsync<ValidationException>(() => context.Users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = invalid }, default));
|
||||
Assert.Equal("aGVsbG8=", await context.Users.GetTopUpQrcode("agent", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonAdmin_CannotModifyQrcode()
|
||||
{
|
||||
using var context = new UserTestContext();
|
||||
context.CurrentUser.UserName = "main";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "aGVsbG8=" }, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
Assert.Equal(0, await context.Db.Select<UserTopUpQrcodeEntity>().CountAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
public sealed class VersionedOpenApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MajorAndMinorVersions_GenerateSeparateDocuments()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Services.AddControllers().AddApplicationPart(typeof(OpenApiMinorVersionTestController).Assembly);
|
||||
builder.Services.AddApiVersioning(options =>
|
||||
{
|
||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
options.AssumeDefaultVersionWhenUnspecified = true;
|
||||
options.ApiVersionReader = new UrlSegmentApiVersionReader();
|
||||
}).AddMvc().AddApiExplorer(options =>
|
||||
{
|
||||
options.GroupNameFormat = "'v'VVV";
|
||||
options.SubstituteApiVersionInUrl = true;
|
||||
}).AddOpenApi();
|
||||
|
||||
await using var app = builder.Build();
|
||||
app.MapControllers();
|
||||
app.MapOpenApi().WithDocumentPerVersion();
|
||||
await app.StartAsync();
|
||||
using var client = app.GetTestClient();
|
||||
|
||||
var groups = app.DescribeApiVersions().Select(version => version.GroupName).ToArray();
|
||||
Assert.Contains("v1", groups);
|
||||
Assert.Contains("v1.1", groups);
|
||||
Assert.Equal(groups.Length, groups.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
|
||||
foreach (var group in new[] { "v1", "v1.1" })
|
||||
{
|
||||
using var response = await client.GetAsync($"/openapi/{group}.json");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var paths = document.RootElement.GetProperty("paths");
|
||||
var ownPath = group == "v1" ? "/default-get-tests/Read" : "/openapi-minor-tests/Read";
|
||||
Assert.True(paths.TryGetProperty(ownPath, out _));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.1")]
|
||||
[Route("openapi-minor-tests/[action]")]
|
||||
public sealed class OpenApiMinorVersionTestController : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public string Read()
|
||||
{
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user