Files
StoreWeb/Tests/CloudProduct.Checks/Program.cs
T
2026-11-06 19:25:36 +08:00

210 lines
15 KiB
C#

using MAX.Plugins;
using Microsoft.Extensions.Hosting.Internal;
using System.Reflection;
using System.Text;
using MAX.Framework.Commons;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using OpenSearch.Client;
using OpenSearch.Net;
using Store.Modules.CloudProduct.Controllers;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Services;
if (args.Contains("--integration"))
{
await IntegrationChecks.RunAsync(args.Contains("--apply-migration"), args.Contains("--migrate-violation-rows"));
return;
}
var checks = 0;
void Check(bool condition, string message)
{
if (!condition) throw new Exception(message);
checks++;
}
void Reject(Action action)
{
try { action(); }
catch (AppException) { checks++; return; }
throw new Exception("Expected validation failure");
}
var grouped = GoodsViolationService.ValidateAndGroup([
new() { GoodsId = 12, Reasons = " 图片违规 " },
new() { GoodsId = 12, Reasons = "描述违规" },
new() { GoodsId = 12, Reasons = "图片违规" },
new() { GoodsId = 12, Reasons = "涉嫌侵权" },
new() { GoodsId = 12, Reasons = "描述违规" },
new() { GoodsId = 13, Reasons = "中文原因" }
]);
Check(grouped.Count == 2 && grouped[12].ToHashSet().SetEquals(new[] { "图片违规", "描述违规", "涉嫌侵权" }), "Duplicate goods/reasons must merge");
Check(grouped[13][0] == "中文原因", "Chinese reason text must survive");
Reject(() => GoodsViolationService.ValidateAndGroup(null));
Reject(() => GoodsViolationService.ValidateAndGroup([]));
Reject(() => GoodsViolationService.ValidateAndGroup([null!]));
Reject(() => GoodsViolationService.ValidateAndGroup([new() { GoodsId = -1, Reasons = "违规" }]));
Reject(() => GoodsViolationService.ValidateAndGroup([new() { GoodsId = 1, Reasons = "" }]));
Reject(() => GoodsViolationService.ValidateAndGroup([new() { GoodsId = 1, Reasons = " " }]));
Reject(() => GoodsViolationService.ValidateAndGroup([new() { GoodsId = 1, Reasons = null! }]));
Reject(() => GoodsViolationService.ValidateAndGroup(new ReportGoodsViolationItem[1001]));
Reject(() => GoodsViolationService.ValidateAndGroup([new() { GoodsId = 1, Reasons = new string('中', 101) }]));
Reject(() => GoodsViolationService.ValidateAndGroup([new() { GoodsId = 1, Reasons = null! }]));
// SQL generation only: no database connection or production data access.
using var db = new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql,
"Server=127.0.0.1;Database=checks;User Id=unused;Password=unused").Build();
// 在内存库验证真实的收藏更新语句和用户隔离,不连接配置中的商品库。
using (var favoriteDb = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.Sqlite, "Data Source=:memory:;Max Pool Size=1").Build())
{
favoriteDb.CodeFirst.SyncStructure<UserGoodsFavoriteEntity>();
favoriteDb.Insert(new[] { "alice", "bob" }.Select(name => new UserGoodsFavoriteEntity
{ UserName = name, GoodsId = 123, CreatedAt = DateTime.Now }).ToArray()).ExecuteAffrows();
var serviceTypeForFavorites = typeof(IUserFavoritesService).Assembly.GetType("Store.Modules.CloudProduct.Services.UserFavoritesService")!;
using var searchContext = new PluginContext("CloudProduct", "Plugins/CloudProduct", new HostingEnvironment());
var indexType = typeof(IUserFavoritesIndexService).Assembly.GetType("Store.Modules.CloudProduct.Services.UserFavoritesIndexService")!;
var index = (IUserFavoritesIndexService)Activator.CreateInstance(indexType, searchContext, null)!;
var indexName = searchContext.Configuration["Search:FavoritesIndexName"]!;
IApiCallDetails? call = null;
void Respond(string body, int status = 200)
{
var settings = new ConnectionSettings(new SingleNodeConnectionPool(new Uri("http://localhost:9200")),
new InMemoryConnection(Encoding.UTF8.GetBytes(body), status)).DefaultIndex(indexName)
.DisableDirectStreaming().OnRequestCompleted(details => call = details);
indexType.GetField("_client", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(index, new OpenSearchClient(settings));
}
Respond("""{"result":"updated","_shards":{"successful":1,"failed":0}}""");
var favorites = (IUserFavoritesService)Activator.CreateInstance(serviceTypeForFavorites,
new Store.Modules.CloudProduct.Repositorys.UserGoodsFavoriteRepository(favoriteDb), null, null, index, null, new LocalFavoritesLock(searchContext))!;
foreach (var safe in new[] { true, false, false })
{
await favorites.SaveProductSafetyAsync(new() { UserName = "alice", ProductId = "123", IsSafe = safe });
using var update = JsonDocument.Parse(call!.RequestBodyInBytes);
Check(update.RootElement.GetProperty("doc").GetProperty("is_safe").GetBoolean() == safe &&
Uri.UnescapeDataString(call.Uri.AbsolutePath).EndsWith("/_update/alice:123") && call.Uri.Query.Contains("refresh=wait_for"),
"Refresh the matching document after a partial safety update");
var rows = favoriteDb.Select<UserGoodsFavoriteEntity>().ToList().ToDictionary(x => x.UserName);
Check(rows["alice"].IsSafe == safe && rows["bob"].IsSafe == null, "Safety updates only the matching favorite");
}
Reject(() => favorites.SaveProductSafetyAsync(new() { UserName = "alice", ProductId = "999", IsSafe = true }).GetAwaiter().GetResult());
Respond("""{"error":{"type":"document_missing_exception","reason":"missing"},"status":404}""", 404);
Reject(() => favorites.SaveProductSafetyAsync(new() { UserName = "alice", ProductId = "123", IsSafe = true }).GetAwaiter().GetResult());
Check(favoriteDb.Select<UserGoodsFavoriteEntity>().Where(x => x.UserName == "alice").First().IsSafe == false, "Roll back on index failure");
Respond("""{"hits":{"total":{"value":12,"relation":"eq"},"hits":[{"_source":{"goods_id":123,"is_safe":false}}]}}""");
var page = await favorites.SearchFavoritesAsync(new() { UserName = "alice", Status = 2, PageIndex = 2, PageSize = 10 });
using var query = JsonDocument.Parse(call!.RequestBodyInBytes);
var filters = query.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter");
Check(filters.EnumerateArray().Any(f => f.GetProperty("term").TryGetProperty("is_safe", out var term) &&
!term.GetProperty("value").GetBoolean()) && query.RootElement.GetProperty("from").GetInt32() == 10,
"Apply safety filter inside paged OpenSearch query");
Check(page.Total == 12 && page.List.Single().IsSafe == false, "Read safety and filtered total from index");
Respond("""{"hits":{"total":{"value":1,"relation":"eq"},"hits":[{"_source":{"goods_id":123}}]}}""");
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var omitted = JsonSerializer.Deserialize<UserFavoritesSearchRequest>("""{"userName":"alice"}""", jsonOptions)!;
await favorites.SearchFavoritesAsync(omitted);
using var allQuery = JsonDocument.Parse(call!.RequestBodyInBytes);
Check(allQuery.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter").GetArrayLength() == 1,
"Omitting status applies only the user filter");
await favorites.SearchFavoritesAsync(new() { UserName = "alice", Status = 0 });
using var zeroQuery = JsonDocument.Parse(call!.RequestBodyInBytes);
Check(zeroQuery.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter").GetArrayLength() == 1, "Status 0 includes all");
await favorites.SearchFavoritesAsync(new() { UserName = "alice", Status = 1 });
using var safeQuery = JsonDocument.Parse(call!.RequestBodyInBytes);
Check(safeQuery.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter").EnumerateArray()
.Any(f => f.GetProperty("term").TryGetProperty("is_safe", out var term) && term.GetProperty("value").GetBoolean()), "Status 1 selects safe");
foreach (int invalid in new[] { -1, 4 })
Reject(() => favorites.SearchFavoritesAsync(new() { UserName = "alice", Status = invalid }).GetAwaiter().GetResult());
var uncheckedRequest = JsonSerializer.Deserialize<UserFavoritesSearchRequest>("""{"userName":"alice","status":3}""", jsonOptions)!;
var uninspected = await favorites.SearchFavoritesAsync(uncheckedRequest);
using var missingQuery = JsonDocument.Parse(call!.RequestBodyInBytes);
var missingFilters = missingQuery.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter");
Check(missingFilters.GetArrayLength() == 2 && missingFilters.EnumerateArray().Any(f => f.TryGetProperty("bool", out var clause)
&& clause.GetProperty("must_not")[0].GetProperty("exists").GetProperty("field").GetString() == "is_safe"),
"Status 3 selects missing safety values alongside the user filter");
Check(uninspected.List.Single().IsSafe == null, "Missing safety remains null");
Check(favoriteDb.Select<UserGoodsFavoriteEntity>().Count() == 2, "Safety reports never create favorites");
}
var sql = db.Select<GoodsEntity>().As("g")
.Where(x => x.Status == 0 && x.IsSegmented == 0)
.Where("NOT EXISTS (SELECT 1 FROM goods_violation v WHERE v.goods_id = g.goods_id)")
.OrderBy(x => x.GoodsId).Limit(1000).ToSql();
Check(sql.Contains("NOT EXISTS") && sql.IndexOf("NOT EXISTS") < sql.IndexOf("limit", StringComparison.OrdinalIgnoreCase),
"Violation filter must precede paging");
Check(sql.Contains("FROM `goods` g", StringComparison.OrdinalIgnoreCase), "Anti-join alias must match: " + sql);
var pendingSql = db.Select<GoodsViolationEntity>().Where(x => !x.IsIndexDeleted).ToSql();
Check(pendingSql.Contains("is_index_deleted") && !pendingSql.Contains("off_shelf_status"), "Renamed column mapping");
var insertSql = db.Insert(new GoodsViolationEntity { GoodsId = 1, ReasonId = 2 }).NoneParameter().ToSql();
Check(insertSql.Contains("reason_id") && !insertSql.Contains("reason_ids"), "Reason must be stored as a scalar ID");
var request = JsonSerializer.Deserialize<ReportGoodsViolationItem>("""{"goodsId":1,"reasons":"图片违规"}""", new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
Check(request.Reasons == "图片违规", "JSON request accepts reason names");
var reportJson = JsonSerializer.Serialize(new ReportGoodsViolationsResult { GoodsId = 1, IsOffShelf = true },
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Check(reportJson == """{"goodsId":1,"isOffShelf":true}""", "Report response contains only goods ID and off-shelf status");
try
{
JsonSerializer.Deserialize<ReportGoodsViolationItem>("""{"goodsId":1,"reasons":["图片违规"]}""", new JsonSerializerOptions(JsonSerializerDefaults.Web));
throw new Exception("Array reason accepted");
}
catch (JsonException) { checks++; }
Check(!typeof(GoodsController).GetMethods().Any(x => x.Name.Contains("ByReason")), "Reason query must not be exposed");
var removalService = new GoodsViolationService(null!, null!, null!, NullLogger<GoodsViolationService>.Instance);
Check(JsonSerializer.Serialize(request, new JsonSerializerOptions(JsonSerializerDefaults.Web)).Contains("shopId") == false,
"report payload excludes log metadata");
Reject(() => removalService.RecordLogsAsync([new() { ShopId = "A", GoodsId = 1, ReasonId = 1, Action = (ViolationLogAction)4 }]).GetAwaiter().GetResult());
Reject(() => removalService.RecordLogsAsync([new() { GoodsId = 1, ReasonId = 1, Action = ViolationLogAction.AutoOffShelf }]).GetAwaiter().GetResult());
Reject(() => removalService.RecordLogsAsync([]).GetAwaiter().GetResult());
Reject(() => removalService.RecordLogsAsync(new RecordViolationLogRequest[1001]).GetAwaiter().GetResult());
Reject(() => removalService.RecordLogsAsync([new() { ShopId = "A", GoodsId = 1, ReasonId = 1, Action = ViolationLogAction.Report }, null!]).GetAwaiter().GetResult());
Reject(() => removalService.GetLogsAsync(["a"], 1, -1, 50).GetAwaiter().GetResult());
Reject(() => removalService.GetLogsAsync(["a"], null, 0, 201).GetAwaiter().GetResult());
Reject(() => removalService.GetLogsAsync([], null, 0, 50).GetAwaiter().GetResult());
Reject(() => removalService.GetLogsAsync(["a", " "], null, 0, 50).GetAwaiter().GetResult());
Reject(() => removalService.RecordLogsAsync([new() { ShopId = "A", GoodsId = 1, ReasonId = 0, Action = ViolationLogAction.AutoOffShelf }]).GetAwaiter().GetResult());
Reject(() => removalService.RemoveAsync(null).GetAwaiter().GetResult());
Reject(() => removalService.RemoveAsync([]).GetAwaiter().GetResult());
Reject(() => removalService.RemoveAsync([0]).GetAwaiter().GetResult());
Reject(() => removalService.RemoveAsync([-1]).GetAwaiter().GetResult());
Reject(() => removalService.RemoveAsync(new long[1001]).GetAwaiter().GetResult());
// Exercise deletion with an in-memory OpenSearch transport, without a real server.
using var context = new PluginContext("CloudProduct", Path.Combine(Directory.GetCurrentDirectory(), "Plugins/CloudProduct"), new HostingEnvironment { EnvironmentName = "Testing" });
context.Configuration["Search:Url"] = "http://localhost:9200";
context.Configuration["Search:IndexName"] = "goods";
var serviceType = typeof(IOpenSearchService).Assembly.GetType("Store.Modules.CloudProduct.Services.OpenSearchService")!;
var logger = Activator.CreateInstance(typeof(NullLogger<>).MakeGenericType(serviceType));
async Task TestDelete(int status, string body, bool shouldFail)
{
var service = (IOpenSearchService)Activator.CreateInstance(serviceType, context, logger, db, new GoodsIndexLock(context))!;
using var transport = new InMemoryConnection(Encoding.UTF8.GetBytes(body), status);
var client = new OpenSearchClient(new ConnectionSettings(new SingleNodeConnectionPool(new Uri("http://localhost:9200")), transport));
serviceType.GetField("_client", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(service, client);
try
{
await service.DeleteDocumentsAsync([123]);
Check(!shouldFail, $"HTTP {status}: expected delete failure");
}
catch (AppException)
{
Check(shouldFail, $"HTTP {status}: unexpected delete failure");
}
}
await TestDelete(403, "{}", true);
await TestDelete(500, "{}", true);
await TestDelete(404, "{}", false);
await TestDelete(200, """{"errors":false,"items":[{"delete":{"_index":"goods","_id":"123","status":200}}]}""", false);
await TestDelete(200, """{"errors":true,"items":[{"delete":{"_index":"goods","_id":"123","status":404,"result":"not_found"}}]}""", false);
await TestDelete(200, """{"errors":true,"items":[{"delete":{"_index":"goods","_id":"123","status":429,"error":{"type":"rejected","reason":"busy"}}}]}""", true);
await TestDelete(200, """{"errors":false,"items":[]}""", true);
Console.WriteLine($"PASS: {checks} checks (no external services accessed)");
// SQLite 检查独占内存库,无需获取 MySQL 会话锁。
sealed class LocalFavoritesLock(PluginContext context) : GoodsIndexLock(context), IAsyncDisposable
{
public override Task<IAsyncDisposable> AcquireFavoritesAsync() => Task.FromResult<IAsyncDisposable>(this);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}