修改User模块,同步原始代码
This commit is contained in:
1 parent
4a5fada435
commit
78b0c2bb2e
47 files changed
+2939
-193
No files matched your search
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user