using System.Text; using System.Text.Json; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using TakeoutSaaS.Application.Dictionary.Contracts; using TakeoutSaaS.Application.Dictionary.Services; using TakeoutSaaS.Domain.Dictionary.Entities; using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Domain.Dictionary.ValueObjects; using TakeoutSaaS.Infrastructure.Dictionary.ImportExport; using TakeoutSaaS.Infrastructure.Dictionary.Repositories; using TakeoutSaaS.Integration.Tests.Fixtures; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; namespace TakeoutSaaS.Integration.Tests.App.Dictionary; public sealed class DictionaryApiTests { [Fact] public async Task Test_CreateDictionaryGroup_ReturnsCreated() { using var database = new DictionarySqliteTestDatabase(); using var context = database.CreateContext(tenantId: 0, userId: 11); var tenantProvider = new TestTenantProvider(0); var cache = new TestDictionaryHybridCache(); var service = BuildCommandService(context, tenantProvider, cache); var result = await service.CreateGroupAsync(new CreateDictionaryGroupRequest { Code = "ORDER_STATUS", Name = "Order Status", Scope = DictionaryScope.System, AllowOverride = true, Description = "Order lifecycle" }); result.Id.Should().NotBe(0); result.Code.Should().Be("order_status"); result.Scope.Should().Be(DictionaryScope.System); using var verifyContext = database.CreateContext(tenantId: 0); var stored = await verifyContext.DictionaryGroups .IgnoreQueryFilters() .FirstOrDefaultAsync(group => group.Id == result.Id); stored.Should().NotBeNull(); stored!.TenantId.Should().Be(0); stored.Code.Value.Should().Be("order_status"); } [Fact] public async Task Test_GetDictionaryGroups_ReturnsPaged() { using var database = new DictionarySqliteTestDatabase(); using var context = database.CreateContext(tenantId: 0, userId: 11); context.DictionaryGroups.AddRange( CreateSystemGroup(101, "order_status"), CreateSystemGroup(102, "payment_method")); await context.SaveChangesAsync(); context.ChangeTracker.Clear(); var tenantProvider = new TestTenantProvider(0); var cache = new TestDictionaryHybridCache(); var service = BuildQueryService(context, tenantProvider, cache); var page = await service.GetGroupsAsync(new DictionaryGroupQuery { Scope = DictionaryScope.System, Page = 1, PageSize = 1 }); page.TotalCount.Should().Be(2); page.Items.Should().HaveCount(1); } [Fact] public async Task Test_UpdateDictionaryGroup_WithValidRowVersion_ReturnsOk() { using var database = new DictionarySqliteTestDatabase(); using var context = database.CreateContext(tenantId: 0, userId: 11); var tenantProvider = new TestTenantProvider(0); var cache = new TestDictionaryHybridCache(); var service = BuildCommandService(context, tenantProvider, cache); var created = await service.CreateGroupAsync(new CreateDictionaryGroupRequest { Code = "PAYMENT_METHOD", Name = "Payment Method", Scope = DictionaryScope.System, AllowOverride = true }); var updated = await service.UpdateGroupAsync(created.Id, new UpdateDictionaryGroupRequest { Name = "Payment Method Updated", Description = "Updated", AllowOverride = false, IsEnabled = false, RowVersion = created.RowVersion }); updated.Name.Should().Be("Payment Method Updated"); updated.AllowOverride.Should().BeFalse(); updated.IsEnabled.Should().BeFalse(); } [Fact] public async Task Test_UpdateDictionaryGroup_WithStaleRowVersion_ReturnsConflict() { using var database = new DictionarySqliteTestDatabase(); using var context = database.CreateContext(tenantId: 0, userId: 11); var tenantProvider = new TestTenantProvider(0); var cache = new TestDictionaryHybridCache(); var service = BuildCommandService(context, tenantProvider, cache); var created = await service.CreateGroupAsync(new CreateDictionaryGroupRequest { Code = "SHIPPING_METHOD", Name = "Shipping Method", Scope = DictionaryScope.System, AllowOverride = true }); var request = new UpdateDictionaryGroupRequest { Name = "Shipping Method Updated", AllowOverride = true, IsEnabled = true, RowVersion = new byte[] { 9 } }; Func act = async () => await service.UpdateGroupAsync(created.Id, request); var exception = await act.Should().ThrowAsync(); exception.Which.ErrorCode.Should().Be(ErrorCodes.Conflict); } [Fact] public async Task Test_DeleteDictionaryGroup_SoftDeletesGroup() { using var database = new DictionarySqliteTestDatabase(); using var context = database.CreateContext(tenantId: 0, userId: 11); var group = CreateSystemGroup(201, "user_role"); var item = CreateSystemItem(210, group.Id, "ADMIN", 10); context.DictionaryGroups.Add(group); context.DictionaryItems.Add(item); await context.SaveChangesAsync(); context.ChangeTracker.Clear(); var tenantProvider = new TestTenantProvider(0); var cache = new TestDictionaryHybridCache(); var service = BuildCommandService(context, tenantProvider, cache); var result = await service.DeleteGroupAsync(group.Id); result.Should().BeTrue(); using var verifyContext = database.CreateContext(tenantId: 0); var deletedGroup = await verifyContext.DictionaryGroups .IgnoreQueryFilters() .FirstAsync(x => x.Id == group.Id); deletedGroup.DeletedAt.Should().NotBeNull(); var deletedItem = await verifyContext.DictionaryItems .IgnoreQueryFilters() .FirstAsync(x => x.Id == item.Id); deletedItem.DeletedAt.Should().NotBeNull(); } [Fact] public async Task Test_EnableOverride_CreatesOverrideConfig() { using var database = new DictionarySqliteTestDatabase(); using (var systemContext = database.CreateContext(tenantId: 0, userId: 11)) { systemContext.DictionaryGroups.Add(CreateSystemGroup(301, "order_status", allowOverride: true)); await systemContext.SaveChangesAsync(); } using var tenantContext = database.CreateContext(tenantId: 100, userId: 21); var cache = new TestDictionaryHybridCache(); var service = BuildOverrideService(tenantContext, cache); var result = await service.EnableOverrideAsync(100, "ORDER_STATUS"); result.OverrideEnabled.Should().BeTrue(); result.SystemDictionaryGroupCode.Should().Be("order_status"); var stored = await tenantContext.TenantDictionaryOverrides .IgnoreQueryFilters() .FirstOrDefaultAsync(x => x.TenantId == 100 && x.SystemDictionaryGroupId == 301); stored.Should().NotBeNull(); stored!.OverrideEnabled.Should().BeTrue(); } [Fact] public async Task Test_DisableOverride_ClearsCustomization() { using var database = new DictionarySqliteTestDatabase(); using (var systemContext = database.CreateContext(tenantId: 0, userId: 11)) { systemContext.DictionaryGroups.Add(CreateSystemGroup(401, "payment_method", allowOverride: true)); await systemContext.SaveChangesAsync(); } using var tenantContext = database.CreateContext(tenantId: 100, userId: 21); var cache = new TestDictionaryHybridCache(); var service = BuildOverrideService(tenantContext, cache); await service.EnableOverrideAsync(100, "payment_method"); var disabled = await service.DisableOverrideAsync(100, "payment_method"); disabled.Should().BeTrue(); var stored = await tenantContext.TenantDictionaryOverrides .IgnoreQueryFilters() .FirstAsync(x => x.TenantId == 100 && x.SystemDictionaryGroupId == 401); stored.OverrideEnabled.Should().BeFalse(); } [Fact] public async Task Test_UpdateHiddenItems_FiltersSystemItems() { using var database = new DictionarySqliteTestDatabase(); using (var systemContext = database.CreateContext(tenantId: 0, userId: 11)) { var group = CreateSystemGroup(501, "shipping_method", allowOverride: true); systemContext.DictionaryGroups.Add(group); systemContext.DictionaryItems.AddRange( CreateSystemItem(510, group.Id, "PLATFORM", 10), CreateSystemItem(511, group.Id, "MERCHANT", 20)); await systemContext.SaveChangesAsync(); } using var tenantContext = database.CreateContext(tenantId: 200, userId: 22); var cache = new TestDictionaryHybridCache(); var overrideService = BuildOverrideService(tenantContext, cache); var mergeService = BuildMergeService(tenantContext); await overrideService.UpdateHiddenItemsAsync(200, "shipping_method", new[] { 511L }); var merged = await mergeService.MergeItemsAsync(200, 501); merged.Should().Contain(item => item.Id == 510); merged.Should().NotContain(item => item.Id == 511); } [Fact] public async Task Test_GetMergedDictionary_ReturnsMergedResult() { using var database = new DictionarySqliteTestDatabase(); using (var systemContext = database.CreateContext(tenantId: 0, userId: 11)) { var group = CreateSystemGroup(601, "order_status", allowOverride: true); systemContext.DictionaryGroups.Add(group); systemContext.DictionaryItems.AddRange( CreateSystemItem(610, group.Id, "PENDING", 10), CreateSystemItem(611, group.Id, "ACCEPTED", 20)); await systemContext.SaveChangesAsync(); } using (var tenantContext = database.CreateContext(tenantId: 300, userId: 33)) { var tenantGroup = CreateTenantGroup(701, 300, "order_status"); tenantContext.DictionaryGroups.Add(tenantGroup); tenantContext.DictionaryItems.Add(CreateTenantItem(720, tenantGroup.Id, "CUSTOM", 15)); await tenantContext.SaveChangesAsync(); } using var queryContext = database.CreateContext(tenantId: 300, userId: 33); var cache = new TestDictionaryHybridCache(); var overrideService = BuildOverrideService(queryContext, cache); await overrideService.EnableOverrideAsync(300, "order_status"); await overrideService.UpdateHiddenItemsAsync(300, "order_status", new[] { 611L }); await overrideService.UpdateCustomSortOrderAsync(300, "order_status", new Dictionary { [720L] = 1, [610L] = 2 }); var queryService = BuildQueryService(queryContext, new TestTenantProvider(300), cache); var merged = await queryService.GetMergedDictionaryAsync("order_status"); merged.Should().Contain(item => item.Id == 610); merged.Should().Contain(item => item.Id == 720 && item.Source == "tenant"); merged.Should().NotContain(item => item.Id == 611); merged.First().Id.Should().Be(720); } [Fact] public async Task Test_ExportCsv_GeneratesValidFile() { using var database = new DictionarySqliteTestDatabase(); using (var systemContext = database.CreateContext(tenantId: 0, userId: 11)) { var group = CreateSystemGroup(801, "payment_method", allowOverride: true); systemContext.DictionaryGroups.Add(group); systemContext.DictionaryItems.Add(CreateSystemItem(810, group.Id, "ALIPAY", 10)); await systemContext.SaveChangesAsync(); } using var exportContext = database.CreateContext(tenantId: 0, userId: 11); var cache = new TestDictionaryHybridCache(); var service = BuildImportExportService(exportContext, new TestTenantProvider(0), new TestCurrentUserAccessor(11), cache); await using var stream = new MemoryStream(); await service.ExportToCsvAsync(801, stream); var csv = Encoding.UTF8.GetString(stream.ToArray()); csv.Should().Contain("code,key,value,sortOrder,isEnabled,description,source"); csv.Should().Contain("payment_method"); csv.Should().Contain("ALIPAY"); } [Fact] public async Task Test_ImportCsv_WithSkipMode_SkipsDuplicates() { using var database = new DictionarySqliteTestDatabase(); using (var systemContext = database.CreateContext(tenantId: 0, userId: 11)) { var group = CreateSystemGroup(901, "order_status", allowOverride: true); systemContext.DictionaryGroups.Add(group); systemContext.DictionaryItems.Add(CreateSystemItem(910, group.Id, "PENDING", 10)); await systemContext.SaveChangesAsync(); } using var importContext = database.CreateContext(tenantId: 0, userId: 11); var cache = new TestDictionaryHybridCache(); var service = BuildImportExportService(importContext, new TestTenantProvider(0), new TestCurrentUserAccessor(11), cache); var csv = BuildCsv( new[] { "code", "key", "value", "sortOrder", "isEnabled", "description", "source" }, new[] { new[] { "order_status", "PENDING", BuildValueJson("待接单", "Pending"), "10", "true", "重复项", "system" }, new[] { "order_status", "COMPLETED", BuildValueJson("已完成", "Completed"), "20", "true", "新增项", "system" } }); var result = await service.ImportFromCsvAsync(new DictionaryImportRequest { GroupId = 901, FileName = "import.csv", FileSize = Encoding.UTF8.GetByteCount(csv), ConflictMode = ConflictResolutionMode.Skip, FileStream = new MemoryStream(Encoding.UTF8.GetBytes(csv)) }); result.SkipCount.Should().Be(1); result.SuccessCount.Should().Be(1); } [Fact] public async Task Test_ImportCsv_WithOverwriteMode_UpdatesExisting() { using var database = new DictionarySqliteTestDatabase(); using (var systemContext = database.CreateContext(tenantId: 0, userId: 11)) { var group = CreateSystemGroup(1001, "payment_method", allowOverride: true); systemContext.DictionaryGroups.Add(group); systemContext.DictionaryItems.Add(CreateSystemItem(1010, group.Id, "ALIPAY", 10)); await systemContext.SaveChangesAsync(); } using var importContext = database.CreateContext(tenantId: 0, userId: 11); var cache = new TestDictionaryHybridCache(); var service = BuildImportExportService(importContext, new TestTenantProvider(0), new TestCurrentUserAccessor(11), cache); var csv = BuildCsv( new[] { "code", "key", "value", "sortOrder", "isEnabled", "description", "source" }, new[] { new[] { "payment_method", "ALIPAY", BuildValueJson("支付宝新版", "Alipay New"), "15", "true", "覆盖", "system" } }); await service.ImportFromCsvAsync(new DictionaryImportRequest { GroupId = 1001, FileName = "import.csv", FileSize = Encoding.UTF8.GetByteCount(csv), ConflictMode = ConflictResolutionMode.Overwrite, FileStream = new MemoryStream(Encoding.UTF8.GetBytes(csv)) }); var updated = await importContext.DictionaryItems .IgnoreQueryFilters() .FirstAsync(item => item.GroupId == 1001 && item.Key == "ALIPAY"); updated.Value.Should().Contain("支付宝新版"); } [Fact] public async Task Test_ImportCsv_WithInvalidData_ReturnsErrors() { using var database = new DictionarySqliteTestDatabase(); using (var systemContext = database.CreateContext(tenantId: 0, userId: 11)) { systemContext.DictionaryGroups.Add(CreateSystemGroup(1101, "user_role", allowOverride: false)); await systemContext.SaveChangesAsync(); } using var importContext = database.CreateContext(tenantId: 0, userId: 11); var cache = new TestDictionaryHybridCache(); var service = BuildImportExportService(importContext, new TestTenantProvider(0), new TestCurrentUserAccessor(11), cache); var csv = BuildCsv( new[] { "code", "key", "value", "sortOrder", "isEnabled", "description", "source" }, new[] { new[] { "user_role", "ADMIN", "invalid-json", "10", "true", "bad", "system" } }); var result = await service.ImportFromCsvAsync(new DictionaryImportRequest { GroupId = 1101, FileName = "import.csv", FileSize = Encoding.UTF8.GetByteCount(csv), ConflictMode = ConflictResolutionMode.Skip, FileStream = new MemoryStream(Encoding.UTF8.GetBytes(csv)) }); result.ErrorCount.Should().Be(1); result.SuccessCount.Should().Be(0); } private static DictionaryCommandService BuildCommandService( TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context, TestTenantProvider tenantProvider, TestDictionaryHybridCache cache) { return new DictionaryCommandService( new DictionaryGroupRepository(context), new DictionaryItemRepository(context), cache, tenantProvider, new HttpContextAccessor { HttpContext = new DefaultHttpContext() }, NullLogger.Instance); } private static DictionaryQueryService BuildQueryService( TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context, TestTenantProvider tenantProvider, TestDictionaryHybridCache cache) { var groupRepository = new DictionaryGroupRepository(context); var itemRepository = new DictionaryItemRepository(context); var overrideRepository = new TenantDictionaryOverrideRepository(context); var mergeService = new DictionaryMergeService(groupRepository, itemRepository, overrideRepository); return new DictionaryQueryService(groupRepository, itemRepository, mergeService, cache, tenantProvider); } private static DictionaryOverrideService BuildOverrideService( TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context, TestDictionaryHybridCache cache) { return new DictionaryOverrideService( new DictionaryGroupRepository(context), new DictionaryItemRepository(context), new TenantDictionaryOverrideRepository(context), cache); } private static DictionaryMergeService BuildMergeService( TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context) { var groupRepository = new DictionaryGroupRepository(context); var itemRepository = new DictionaryItemRepository(context); var overrideRepository = new TenantDictionaryOverrideRepository(context); return new DictionaryMergeService(groupRepository, itemRepository, overrideRepository); } private static DictionaryImportExportService BuildImportExportService( TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext context, TestTenantProvider tenantProvider, TestCurrentUserAccessor currentUser, TestDictionaryHybridCache cache) { return new DictionaryImportExportService( new CsvDictionaryParser(), new JsonDictionaryParser(), new DictionaryGroupRepository(context), new DictionaryItemRepository(context), new DictionaryImportLogRepository(context), cache, tenantProvider, currentUser, new HttpContextAccessor { HttpContext = new DefaultHttpContext() }, NullLogger.Instance); } private static DictionaryGroup CreateSystemGroup(long id, string code, bool allowOverride = true) { return new DictionaryGroup { Id = id, TenantId = 0, Code = new DictionaryCode(code), Name = code, Scope = DictionaryScope.System, AllowOverride = allowOverride, Description = "Test group", IsEnabled = true, RowVersion = new byte[] { 1 } }; } private static DictionaryGroup CreateTenantGroup(long id, long tenantId, string code) { return new DictionaryGroup { Id = id, TenantId = tenantId, Code = new DictionaryCode(code), Name = code, Scope = DictionaryScope.Business, AllowOverride = false, Description = "Tenant group", IsEnabled = true, RowVersion = new byte[] { 1 } }; } private static DictionaryItem CreateSystemItem(long id, long groupId, string key, int sortOrder) { return new DictionaryItem { Id = id, TenantId = 0, GroupId = groupId, Key = key, Value = BuildValueJson("测试", "Test"), IsDefault = false, IsEnabled = true, SortOrder = sortOrder, Description = "System item", RowVersion = new byte[] { 1 } }; } private static DictionaryItem CreateTenantItem(long id, long groupId, string key, int sortOrder) { return new DictionaryItem { Id = id, TenantId = 300, GroupId = groupId, Key = key, Value = BuildValueJson("租户值", "Tenant Value"), IsDefault = false, IsEnabled = true, SortOrder = sortOrder, Description = "Tenant item", RowVersion = new byte[] { 1 } }; } private static string BuildValueJson(string zh, string en) { var value = new I18nValue(new Dictionary { ["zh-CN"] = zh, ["en"] = en }); return value.ToJson(); } private static string BuildCsv(string[] headers, IEnumerable rows) { var builder = new StringBuilder(); builder.AppendLine(string.Join(",", headers)); foreach (var row in rows) { builder.AppendLine(string.Join(",", row.Select(EscapeCsvField))); } return builder.ToString(); } private static string EscapeCsvField(string value) { if (value.Contains('"', StringComparison.Ordinal)) { value = value.Replace("\"", "\"\""); } if (value.Contains(',', StringComparison.Ordinal) || value.Contains('\n', StringComparison.Ordinal) || value.Contains('\r', StringComparison.Ordinal) || value.Contains('"', StringComparison.Ordinal)) { return $"\"{value}\""; } return value; } }