Files
2026-10-28 11:35:48 +08:00

174 lines
9.3 KiB
C#

using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Text;
using Store.Management.Api;
using Store.Management.Api.Authentication;
using Store.Management.Api.Transport;
using Store.Management.Api.Users;
static void Check(bool condition, string message)
{
if (!condition) throw new Exception(message);
}
static async Task Error(Func<Task> action, ApiErrorKind kind)
{
try { await action(); }
catch (ApiException ex) { Check(ex.Kind == kind, $"预期 {kind},实际 {ex.Kind}"); return; }
throw new Exception($"未抛出 {kind}");
}
var handler = new StubHandler();
using var http = new HttpClient(handler);
using var api = new StoreApiClient(new ApiClientOptions { BaseUrl = new Uri("https://test.invalid/root/") }, http);
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Unauthorized);
Check(handler.Count == 0, "未登录不应发送请求");
handler.Json = """{"code":200,"data":{"token":"session-one","tokenType":"Bearer","expiresInMinutes":100}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
Check(handler.Path == "/root/v1/Auth/Login" && handler.Authorization is null, "登录路由或匿名认证错误");
handler.Json = """{"code":200,"data":{"total":1,"items":[{"id":3,"userName":"alice"}]}}""";
var page = await api.Users.SearchAsync(new UserSearchRequest { Page = 2, PageSize = 10 });
Check(page.Total == 1 && page.Items[0].UserName == "alice", "分页解析失败");
Check(handler.Authorization == "Bearer session-one" && handler.Body!.Contains("\"page\":2"), "Token 或分页请求体错误");
var count = handler.Count;
try { await api.Users.SearchAsync(new UserSearchRequest { Page = 0 }); throw new Exception("未校验分页"); }
catch (ValidationException) { Check(handler.Count == count, "无效参数不应发送"); }
handler.Json = """{"code":2001,"msg":"业务失败","traceId":"trace-1","data":null}""";
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Business);
Check(api.Session.IsAuthenticated, "业务失败不应退出会话");
handler.Json = "not-json";
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.InvalidResponse);
handler.Status = HttpStatusCode.Forbidden;
handler.Json = """{"code":403,"msg":"无权限","data":null}""";
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Http);
Check(api.Session.IsAuthenticated, "403 不应清除会话");
handler.Status = HttpStatusCode.OK;
handler.Json = """{"code":401,"msg":"已失效","data":null}""";
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Unauthorized);
Check(!api.Session.IsAuthenticated, "业务401应清理会话");
handler.Json = """{"code":200,"data":{"token":"session-two"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
handler.Status = HttpStatusCode.Unauthorized;
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Unauthorized);
Check(!api.Session.IsAuthenticated, "HTTP401应清理会话");
handler.Status = HttpStatusCode.OK;
handler.Json = """{"code":200,"data":{"token":"session-three"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
handler.Json = """{"code":200,"data":null}""";
await api.Auth.LogoutAsync();
Check(!api.Session.IsAuthenticated && handler.Path.EndsWith("Auth/Logout"), "退出失败");
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
try { await api.Auth.LoginAsync(new LoginRequest(), cancelled.Token); throw new Exception("取消未生效"); }
catch (OperationCanceledException) { }
// 错误信封的 data 不必符合成功 DTO,仍须保留错误类型与追踪信息。
handler.Json = """{"code":200,"data":{"token":"session-four"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
handler.Json = """{"code":400,"msg":"账号错误","traceId":"error-data","data":[]} """;
try
{
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "wrong" });
throw new Exception("错误登录应失败");
}
catch (ApiException ex)
{
Check(ex.Kind == ApiErrorKind.Business && ex.Code == 400 && ex.TraceId == "error-data"
&& ex.Message == "账号错误", "错误 data 不应掩盖业务错误");
}
Check(api.Session.IsAuthenticated, "失败登录不应替换原会话");
handler.Json = """{"code":200,"data":{"token":"","tokenType":"Bearer"}}""";
await Error(async () => await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }), ApiErrorKind.InvalidResponse);
Check(api.Session.IsAuthenticated, "无效 Token 不应替换原会话");
handler.Json = """{"code":200,"traceId":"bad-data","data":[]} """;
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.InvalidResponse);
handler.Json = """{"code":200,"data":null}""";
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.InvalidResponse);
handler.Json = """{"code":401,"traceId":"expired","data":[]} """;
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Unauthorized);
Check(!api.Session.IsAuthenticated, "data 类型不匹配时业务 401 仍应清理会话");
handler.Json = """{"code":200,"data":{"token":"old-token"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
TaskCompletionSource arrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
handler.BeforeResponse = async cancellationToken =>
{
arrived.SetResult();
await release.Task.WaitAsync(cancellationToken);
};
handler.Json = """{"code":401,"data":[]} """;
Task<CurrentUser> oldRequest = api.Auth.MeAsync();
await arrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
handler.BeforeResponse = null;
handler.Json = """{"code":200,"data":{"token":"new-token"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
release.SetResult();
await Error(async () => await oldRequest, ApiErrorKind.Unauthorized);
Check(api.Session.IsAuthenticated, "旧请求 401 不得清理新会话");
handler.Json = """{"code":200,"data":{"userName":"admin"}}""";
await api.Auth.MeAsync();
Check(handler.Authorization == "Bearer new-token", "重新登录应使用新 Token");
arrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
release = new(TaskCreationOptions.RunContinuationsAsynchronously);
handler.BeforeResponse = async cancellationToken =>
{
arrived.SetResult();
await release.Task.WaitAsync(cancellationToken);
};
handler.Json = """{"code":200,"data":{"token":"last-token"}}""";
Task<LoginResult> pendingLogin = api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
await arrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
int beforeLogout = handler.Count;
Task pendingLogout = api.Auth.LogoutAsync();
Check(handler.Count == beforeLogout && !pendingLogout.IsCompleted, "退出必须等待登录完成");
handler.BeforeResponse = null;
handler.Json = """{"code":200,"data":null}""";
release.SetResult();
await Task.WhenAll(pendingLogin, pendingLogout).WaitAsync(TimeSpan.FromSeconds(5));
Check(!api.Session.IsAuthenticated && handler.Authorization == "Bearer last-token", "退出应撤销刚完成的登录");
handler.Json = """{"code":200,"data":{"token":"logout-token"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
handler.Status = HttpStatusCode.InternalServerError;
handler.Json = "server-error";
await Error(() => api.Auth.LogoutAsync(), ApiErrorKind.Http);
Check(!api.Session.IsAuthenticated, "退出失败也必须清理本地会话");
handler.Status = HttpStatusCode.OK;
handler.BeforeResponse = cancellationToken => Task.Delay(Timeout.Infinite, cancellationToken);
using CancellationTokenSource inFlight = new(TimeSpan.FromMilliseconds(100));
try
{
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }, inFlight.Token);
throw new Exception("请求中取消未生效");
}
catch (OperationCanceledException)
{
Check(!api.Session.IsAuthenticated, "取消登录不得保存会话");
}
handler.BeforeResponse = null;
Console.WriteLine("PASS: 登录、路由、Bearer、分页、校验、错误分类、无效 Token/data、401 会话隔离、登录退出串行、退出失败清理、取消。");
sealed class StubHandler : HttpMessageHandler
{
public string Json { get; set; } = "";
public HttpStatusCode Status { get; set; } = HttpStatusCode.OK;
public int Count { get; private set; }
public string Path { get; private set; } = "";
public string? Authorization { get; private set; }
public string? Body { get; private set; }
public Func<CancellationToken, Task>? BeforeResponse { get; set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Count++;
Path = request.RequestUri!.AbsolutePath;
Authorization = request.Headers.Authorization?.ToString();
Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
HttpStatusCode status = Status;
string json = Json;
if (BeforeResponse is not null)
{
await BeforeResponse(cancellationToken);
}
return new HttpResponseMessage(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
}
}