增加Shop API

This commit is contained in:
chfei committed 2026-10-29 11:34:35 +08:00
1 parent 3769193696
commit 5d017740c6
83 files changed
+4451 -696

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using Asp.Versioning;
using MAX.Framework.Commons;
using MAX.Framework.Conventions;
using MAX.Framework.Dtos;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Store.Shops.Controllers;
using Store.Shops.Entities;
using Store.Shops.Services;
using Store.Users.Contracts;
namespace Store.Shops.Tests;
public sealed class ShopHttpTests : IAsyncLifetime
{
private readonly ShopTestContext 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();
builder.Services.AddControllers(options =>
{
options.Conventions.Add(new ApiRoutePrefixConvention());
options.Filters.Add<GlobalExceptionFilter>();
options.Filters.Add<UnifyResultFilter>();
}).AddApplicationPart(typeof(ShopController).Assembly).ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = _ =>
new ObjectResult(ApiResult.Fail("参数错误", 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, ShopTestAuthentication>("Test", _ => { });
builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
builder.Services.AddSingleton(context.Db);
builder.Services.AddSingleton<IUserQueryService>(context);
builder.Services.AddSingleton(context.Configuration);
builder.Services.AddSingleton(context.Gateway);
builder.Services.AddTransient<ShopManagementService>();
builder.Services.AddTransient<ShopService>();
app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
await app.StartAsync();
client = app.GetTestClient();
client.DefaultRequestHeaders.Add("X-Test-User", "owner");
}
[Theory]
[InlineData("GetPddClients")]
[InlineData("GetPddShopClients")]
[InlineData("GetPddShopToken")]
[InlineData("GetAllPddShops")]
[InlineData("AddWeidianShop")]
[InlineData("UpdateWeidianShop")]
[InlineData("UpdateWeidianShopLogin")]
[InlineData("DeleteWeidianShop")]
public async Task ActionsOnlyAcceptPost(string action)
{
using var response = await client.GetAsync("/api/v1/shop/" + action);
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
Assert.Contains("POST", response.Content.Headers.Allow);
}
[Fact]
public async Task ActionsBindDtoAndReturnSingleApiResultEnvelope()
{
using var create = await client.PostAsJsonAsync("/api/v1/shop/AddWeidianShop", new { loginName = "login", shopId = "shop", notes = "old" });
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var json = await create.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(200, json.GetProperty("code").GetInt32());
Assert.Equal(JsonValueKind.Null, json.GetProperty("data").ValueKind);
using var update = await client.PostAsJsonAsync("/api/v1/shop/UpdateWeidianShop", new { loginName = "login", shopId = "shop", notes = "new" });
Assert.Equal(200, (await update.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
using var cookie = await client.PostAsJsonAsync("/api/v1/shop/UpdateWeidianShopLogin", new { shopId = "shop", cookie = "session" });
Assert.Equal(200, (await cookie.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
var row = Assert.Single(await context.Db.Select<WeidianShopEntity>().ToListAsync());
Assert.Equal("new", row.Notes);
Assert.Equal("session", row.Cookie);
Assert.Equal("owner", row.UserName);
}
[Fact]
public async Task ValidationAndBusinessErrorsReturnHttp200WithFailureCode()
{
using var invalid = await client.PostAsJsonAsync("/api/v1/shop/AddWeidianShop", new { });
Assert.Equal(HttpStatusCode.OK, invalid.StatusCode);
Assert.Equal(ResultCode.ValidationError, (await invalid.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
using var denied = await client.PostAsJsonAsync("/api/v1/shop/GetAllPddShops", new { });
Assert.Equal(HttpStatusCode.OK, denied.StatusCode);
Assert.Equal(ResultCode.BusinessError, (await denied.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
}
[Fact]
public async Task CredentialResponseIsWrappedAndNotCached_AnonymousCannotRead()
{
using var response = await client.PostAsJsonAsync("/api/v1/shop/GetPddClients", new { });
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(200, result.GetProperty("code").GetInt32());
Assert.Equal("test-client", result.GetProperty("data")[0].GetProperty("clientId").GetString());
Assert.True(response.Headers.CacheControl!.NoStore);
client.DefaultRequestHeaders.Remove("X-Test-User");
using var anonymous = await client.PostAsJsonAsync("/api/v1/shop/GetPddClients", new { });
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
public async Task DisposeAsync()
{
client.Dispose();
await app.DisposeAsync();
context.Dispose();
}
}
internal sealed class ShopTestAuthentication(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,184 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using MAX.Framework.Commons;
using Store.Shops.Dtos;
using Store.Shops.Entities;
namespace Store.Shops.Tests;
public sealed class ShopManagementTests
{
[Fact]
public async Task CreateThenImportAgain_UpdatesWithoutDuplicating_AndNullPreservesFields()
{
using var c = new ShopTestContext();
await c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Notes = "keep", Cookie = "old" }, true);
await c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Cookie = "new" }, true);
var row = Assert.Single(await c.Db.Select<WeidianShopEntity>().ToListAsync());
Assert.Equal("keep", row.Notes);
Assert.Equal("new", row.Cookie);
Assert.Equal("owner", row.UserName);
await c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Notes = "", NewShopId = "new-id" }, false);
row = await c.Db.Select<WeidianShopEntity>().FirstAsync();
Assert.Equal("", row.Notes);
Assert.Equal("new-id", row.ShopId);
}
[Fact]
public async Task WrongOwnerCannotUpdateCookieOrDelete_EvenWithSameShopId()
{
using var c = new ShopTestContext();
await c.Db.Insert(new WeidianShopEntity { LoginName = "login", ShopId = "shop", UserName = "other", Cookie = "secret" }).ExecuteAffrowsAsync();
await Assert.ThrowsAsync<AppException>(() => c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "attack" }));
await Assert.ThrowsAsync<AppException>(() => c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Notes = "attack" }, false));
await Assert.ThrowsAsync<AppException>(() => c.Service.DeleteWeidian(new() { ShopId = "shop" }));
Assert.Equal("secret", (await c.Db.Select<WeidianShopEntity>().FirstAsync()).Cookie);
}
[Fact]
public async Task AssignedAccountMatchesWholeName_AndCannotReassignOrDelete()
{
using var c = new ShopTestContext();
await c.Db.Insert(new WeidianShopEntity { LoginName = "login", ShopId = "shop", UserName = "other", SubUserName = "coowner,owner2" }).ExecuteAffrowsAsync();
await Assert.ThrowsAsync<AppException>(() => c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "attack" }));
await c.Db.Update<WeidianShopEntity>().Where(s => s.ShopId == "shop").Set(s => s.SubUserName, "first,owner,last").ExecuteAffrowsAsync();
await c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "allowed" });
await Assert.ThrowsAsync<AppException>(() => c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", SubUserName = "new" }, false));
await Assert.ThrowsAsync<AppException>(() => c.Service.DeleteWeidian(new() { ShopId = "shop" }));
Assert.Equal("allowed", (await c.Db.Select<WeidianShopEntity>().FirstAsync()).Cookie);
}
[Fact]
public async Task CookieUpdateOnlyChangesAuthorizedRows_AndOwnerCanDelete()
{
using var c = new ShopTestContext();
await c.Db.Insert(new[] {
new WeidianShopEntity { ShopId = "shop", UserName = "owner", WdToken = "keep" },
new WeidianShopEntity { ShopId = "shop", UserName = "other", Cookie = "secret" }
}).ExecuteAffrowsAsync();
await c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "new", LoginStatus = "online" });
Assert.Equal("keep", (await c.Db.Select<WeidianShopEntity>().Where(s => s.UserName == "owner").FirstAsync()).WdToken);
await c.Service.DeleteWeidian(new() { ShopId = "shop" });
Assert.Equal("secret", Assert.Single(await c.Db.Select<WeidianShopEntity>().ToListAsync()).Cookie);
}
[Fact]
public async Task AmbiguousLoginIsRejectedWithoutChangingEitherShop()
{
using var c = new ShopTestContext();
await c.Service.SaveWeidian(new() { LoginName = "same", ShopId = "one" }, true);
await c.Service.SaveWeidian(new() { LoginName = "same", ShopId = "two" }, true);
await Assert.ThrowsAsync<AppException>(() => c.Service.SaveWeidian(new() { LoginName = "same", Notes = "bad" }, false));
Assert.All(await c.Db.Select<WeidianShopEntity>().ToListAsync(), s => Assert.Equal("", s.Notes));
}
[Fact]
public async Task ValidationAndAnonymousRequestsDoNotWrite()
{
using var c = new ShopTestContext();
await Assert.ThrowsAsync<ValidationException>(() => c.Service.SaveWeidian(new(), true));
await Assert.ThrowsAsync<ValidationException>(() => c.Service.UpdateWeidianCookie(new() { ShopId = "shop", WdToken = "only" }));
await Assert.ThrowsAsync<ValidationException>(() => c.Service.DeleteWeidian(new()));
c.UserName = null;
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => c.Service.SaveWeidian(new() { LoginName = "x" }, true));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => c.Service.GetPddClients());
Assert.Equal(0, await c.Db.Select<WeidianShopEntity>().CountAsync());
}
[Theory]
[InlineData(0, 50)]
[InlineData(1, 0)]
[InlineData(1, 501)]
[InlineData(int.MaxValue, 500)]
public async Task InvalidPaginationIsRejected(int page, int size)
{
using var c = new ShopTestContext();
await Assert.ThrowsAsync<ValidationException>(() => c.Service.GetAdminShops(new() { Page = page, PageSize = size }));
await Assert.ThrowsAsync<ValidationException>(() => c.Service.GetShopClientInfo(new() { Page = page, PageSize = size }));
}
[Fact]
public async Task ClientInfoScopesCountAndRowsBeforePagination()
{
using var c = new ShopTestContext();
await c.Db.Insert(new[] {
new PddShopEntity { ShopId = "one", UserName = "owner" },
new PddShopEntity { ShopId = "two", UserName = "other", SubUserName = "owner" },
new PddShopEntity { ShopId = "three", UserName = "other", SubUserName = "owner2" }
}).ExecuteAffrowsAsync();
foreach (var id in new[] { "one", "two", "three" })
await c.Db.Insert(new ShopApiAuthEntity { ShopId = id, AppKey = id, AppSecret = "secret" }).ExecuteAffrowsAsync();
var page = await c.Service.GetShopClientInfo(new() { PageSize = 1 });
Assert.Equal(2, page.Total);
Assert.Equal("two", Assert.Single(page.Items).ShopId);
Assert.Empty((await c.Service.GetShopClientInfo(new() { ShopIds = ["three"] })).Items);
c.UserName = "admin";
Assert.Empty((await c.Service.GetShopClientInfo(new())).Items);
}
[Fact]
public async Task AdminListResetsOldMonthOnceAndKeepsCurrentQuota()
{
using var c = new ShopTestContext();
await Assert.ThrowsAsync<AppException>(() => c.Service.GetAdminShops(new()));
c.UserName = "admin";
await c.Db.Insert(new PddShopEntity { ShopId = "old", ShopAccount = "test", LoginPass = "password", Cookie = "secret" }).ExecuteAffrowsAsync();
await c.Db.Insert(new ShopProductQuotaEntity { ShopId = "old", AddProductMonth = DateTime.Now.Month % 12 + 1, AddProductRemaining = 2 }).ExecuteAffrowsAsync();
var page = await c.Service.GetAdminShops(new() { SearchName = "test" });
Assert.Equal(1, page.Total);
Assert.Equal(300, Assert.Single(page.Items).AddProductRemaining);
var json = JsonSerializer.Serialize(page);
Assert.DoesNotContain("LoginPass", json);
Assert.DoesNotContain("Cookie", json);
await c.Db.Update<ShopProductQuotaEntity>().Where(s => s.ShopId == "old").Set(s => s.AddProductRemaining, 17).ExecuteAffrowsAsync();
Assert.Equal(17, Assert.Single((await c.Service.GetAdminShops(new())).Items).AddProductRemaining);
Assert.Equal(17, (await c.Db.Select<PddShopEntity>().FirstAsync()).AddProductRemaining);
}
[Fact]
public async Task QuotaAndShopUpdatesRollbackTogether()
{
using var c = new ShopTestContext { UserName = "admin" };
await c.Db.Insert(new PddShopEntity { ShopId = "shop" }).ExecuteAffrowsAsync();
await c.Db.Insert(new ShopProductQuotaEntity { ShopId = "shop", AddProductMonth = 0, AddProductRemaining = 7 }).ExecuteAffrowsAsync();
c.Db.Ado.ExecuteNonQuery("CREATE TRIGGER reject_shop_update BEFORE UPDATE ON pdd_shops BEGIN SELECT RAISE(ABORT, 'test failure'); END");
await Assert.ThrowsAnyAsync<Exception>(() => c.Service.GetAdminShops(new()));
var quota = await c.Db.Select<ShopProductQuotaEntity>().FirstAsync();
Assert.Equal(0, quota.AddProductMonth);
Assert.Equal(7, quota.AddProductRemaining);
}
[Fact]
public async Task TokenRequiresShopAccess_AndRetriesStructuredFailure()
{
using var c = new ShopTestContext();
await Assert.ThrowsAsync<AppException>(() => c.Service.GetPddToken(new() { MallId = "mall" }));
Assert.Equal(0, c.Handler.Calls);
await c.Db.Insert(new PddShopEntity { MallId = "mall&x=1", UserName = "owner" }).ExecuteAffrowsAsync();
c.Handler.Responses.Enqueue("""{"code":1,"msg":"failed"}""");
var token = await c.Service.GetPddToken(new() { MallId = "mall&x=1" });
Assert.Equal("test-token", token.GetProperty("accessToken").GetString());
Assert.Equal(2, c.Handler.Calls);
Assert.Contains("mall%26x%3D1", c.Handler.LastUri!.AbsoluteUri);
}
[Fact]
public async Task TokenMalformedResponsesFailWithoutLeakingUpstreamContent()
{
using var c = new ShopTestContext();
c.Handler.Responses.Enqueue("private upstream error");
c.Handler.Responses.Enqueue("""{"code":3}""");
var error = await Assert.ThrowsAsync<AppException>(() => c.Gateway.GetToken("mall", default));
Assert.DoesNotContain("private", error.Message);
Assert.Equal(2, c.Handler.Calls);
}
[Fact]
public async Task PddClientsComeFromConfiguration()
{
using var c = new ShopTestContext();
Assert.Equal("test-client", Assert.Single(await c.Service.GetPddClients()).ClientId);
c.Configuration["Shops:PddClients:0:ClientSecret"] = "";
await Assert.ThrowsAsync<AppException>(() => c.Service.GetPddClients());
}
}
@@ -0,0 +1,75 @@
using System.Net;
using System.Security.Claims;
using FreeSql;
using MAX.Framework.Security;
using Microsoft.Extensions.Configuration;
using Store.Shops.Entities;
using Store.Shops.Services;
using Store.Users.Contracts;
using Store.Users.Contracts.Dtos;
using Store.Users.Contracts.Enums;
namespace Store.Shops.Tests;
internal sealed class ShopTestContext : IDisposable, ICurrentUser, IUserQueryService
{
private readonly string path = Path.Combine(Path.GetTempPath(), $"store-shops-{Guid.NewGuid():N}.db");
public IFreeSql Db { get; }
public string? UserName { get; set; } = "owner";
public string? NickName => UserName;
public bool IsAuthenticated => UserName != null;
public IEnumerable<Claim> Claims => [];
public string? GetClaimValue(string type) => null;
public T? GetClaimValue<T>(string type) => default;
public Task<UserInfo> GetUser(string name) => Task.FromResult(new UserInfo
{
UserName = name, SaLevel = name == "admin" ? UserAdminLevel.SuperAdmin : UserAdminLevel.Normal
});
public StubHttp Handler { get; } = new();
public PddTokenClient Gateway { get; }
public ShopManagementService Service { get; }
public IConfiguration Configuration { get; } = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Shops:PddClients:0:ClientId"] = "test-client",
["Shops:PddClients:0:ClientSecret"] = "test-secret",
["Shops:PddClients:0:RedirectUri"] = "https://example.test/callback"
}).Build();
public ShopTestContext()
{
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();
Db.CodeFirst.ConfigEntity<PddShopEntity>(e => e.Name("pdd_shops"));
Db.CodeFirst.ConfigEntity<WeidianShopEntity>(e => e.Name("weidian_shops"));
Db.CodeFirst.ConfigEntity<ShopApiAuthEntity>(e => e.Name("shop_auth"));
Db.CodeFirst.ConfigEntity<ShopProductQuotaEntity>(e => e.Name("shop_quota"));
Db.CodeFirst.SyncStructure(typeof(PddShopEntity), typeof(WeidianShopEntity), typeof(ShopApiAuthEntity), typeof(ShopProductQuotaEntity));
Gateway = new(new HttpClient(Handler));
Service = new(Db, this, this, Configuration, Gateway);
}
public void Dispose()
{
Gateway.Dispose();
Db.Dispose();
File.Delete(path);
}
}
internal sealed class StubHttp : HttpMessageHandler
{
public Queue<string> Responses { get; } = new();
public int Calls { get; private set; }
public Uri? LastUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
Calls++;
LastUri = request.RequestUri;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(Responses.Count == 0 ? """{"code":0,"data":{"accessToken":"test-token"}}""" : Responses.Dequeue())
});
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../Modules/Store.Shops/Store.Shops.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>