Files
TakeoutSaaS.TenantApi/tests/TakeoutSaaS.Integration.Tests/App/Dictionary/DictionaryApiTests.cs

607 lines
23 KiB
C#

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<Task> act = async () => await service.UpdateGroupAsync(created.Id, request);
var exception = await act.Should().ThrowAsync<BusinessException>();
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<long, int>
{
[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,
NullLogger<DictionaryCommandService>.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,
NullLogger<DictionaryImportExportService>.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<string, string>
{
["zh-CN"] = zh,
["en"] = en
});
return value.ToJson();
}
private static string BuildCsv(string[] headers, IEnumerable<string[]> 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;
}
}