refactor: 移除 tests 目录

This commit is contained in:
2026-01-30 00:53:58 +00:00
parent 4f8424adb6
commit 3b3a29cb91
21 changed files with 0 additions and 1978 deletions

View File

@@ -1,54 +0,0 @@
using FluentAssertions;
using Moq;
using TakeoutSaaS.Application.App.Tenants.Handlers;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Application.Tests.TestUtilities;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.Tests.App.Tenants.Handlers;
/// <summary>
/// <see cref="GetAnnouncementByIdQueryHandler"/> 单元测试。
/// </summary>
public sealed class GetAnnouncementByIdQueryHandlerTests
{
[Fact]
public async Task GivenAnnouncementMissing_WhenHandle_ThenReturnsNull()
{
// 1. 准备
var announcementRepository = new Mock<ITenantAnnouncementRepository>();
announcementRepository
.Setup(x => x.FindByIdAsync(99, 500, It.IsAny<CancellationToken>()))
.ReturnsAsync((TenantAnnouncement?)null);
// 2. (空行后) 执行
var handler = new GetAnnouncementByIdQueryHandler(announcementRepository.Object);
var result = await handler.Handle(new GetAnnouncementByIdQuery { TenantId = 99, AnnouncementId = 500 }, CancellationToken.None);
// 3. (空行后) 断言
result.Should().BeNull();
}
[Fact]
public async Task GivenAnnouncementExists_WhenHandle_ThenReturnsDtoWithoutReadState()
{
// 1. 准备
var announcement = AnnouncementTestData.CreateAnnouncement(10, 200, 1, DateTime.UtcNow);
var announcementRepository = new Mock<ITenantAnnouncementRepository>();
announcementRepository
.Setup(x => x.FindByIdAsync(200, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(announcement);
// 2. (空行后) 执行
var handler = new GetAnnouncementByIdQueryHandler(announcementRepository.Object);
var result = await handler.Handle(new GetAnnouncementByIdQuery { TenantId = 200, AnnouncementId = 10 }, CancellationToken.None);
// 3. (空行后) 断言
result.Should().NotBeNull();
result!.Id.Should().Be(announcement.Id);
result.TenantId.Should().Be(announcement.TenantId);
result.IsRead.Should().BeFalse();
result.ReadAt.Should().BeNull();
}
}

View File

@@ -1,122 +0,0 @@
using FluentAssertions;
using Moq;
using TakeoutSaaS.Application.App.Tenants.Handlers;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Application.Tests.TestUtilities;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.Tests.App.Tenants.Handlers;
/// <summary>
/// <see cref="GetTenantsAnnouncementsQueryHandler"/> 单元测试。
/// </summary>
public sealed class GetTenantsAnnouncementsQueryHandlerTests
{
[Fact]
public async Task GivenQuery_WhenHandle_ThenOrdersAndPaginates()
{
// 1. 准备
var announcements = new List<TakeoutSaaS.Domain.Tenants.Entities.TenantAnnouncement>
{
AnnouncementTestData.CreateAnnouncement(1, 42, priority: 1, effectiveFrom: DateTime.UtcNow.AddDays(-1)),
AnnouncementTestData.CreateAnnouncement(2, 42, priority: 2, effectiveFrom: DateTime.UtcNow.AddDays(-3)),
AnnouncementTestData.CreateAnnouncement(3, 42, priority: 2, effectiveFrom: DateTime.UtcNow.AddDays(-1)),
AnnouncementTestData.CreateAnnouncement(4, 42, priority: 0, effectiveFrom: DateTime.UtcNow)
};
// 2. (空行后) 模拟数据库端排序:按 priority DESC, effectiveFrom DESC
var sortedAnnouncements = announcements
.OrderByDescending(x => x.Priority)
.ThenByDescending(x => x.EffectiveFrom)
.ToList();
var announcementRepository = new Mock<ITenantAnnouncementRepository>();
announcementRepository
.Setup(x => x.SearchAsync(
It.IsAny<long>(),
It.IsAny<string?>(),
It.IsAny<TakeoutSaaS.Domain.Tenants.Enums.AnnouncementStatus?>(),
It.IsAny<TakeoutSaaS.Domain.Tenants.Enums.TenantAnnouncementType?>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>(),
It.IsAny<DateTime?>(),
It.IsAny<DateTime?>(),
It.IsAny<bool>(),
It.IsAny<int?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(sortedAnnouncements);
// 3. (空行后) 执行
var handler = new GetTenantsAnnouncementsQueryHandler(announcementRepository.Object);
var query = new GetTenantsAnnouncementsQuery
{
TenantId = 42,
Page = 2,
PageSize = 2
};
var result = await handler.Handle(query, CancellationToken.None);
// 4. (空行后) 断言
announcementRepository.Verify(x => x.SearchAsync(
query.TenantId,
query.Keyword,
query.Status,
query.AnnouncementType,
query.IsActive,
query.EffectiveFrom,
query.EffectiveTo,
null,
true,
12, // estimatedLimit = page * size * 3 = 2 * 2 * 3 = 12
It.IsAny<CancellationToken>()), Times.Once);
result.TotalCount.Should().Be(4);
result.Items.Select(x => x.Id).Should().Equal(1, 4);
result.Page.Should().Be(2);
result.PageSize.Should().Be(2);
}
[Fact]
public async Task GivenOnlyEffective_WhenHandle_ThenFiltersScheduledPublish()
{
// 1. 准备
var announcement1 = AnnouncementTestData.CreateAnnouncement(1, 42, priority: 1, effectiveFrom: DateTime.UtcNow.AddDays(-1));
announcement1.ScheduledPublishAt = DateTime.UtcNow.AddMinutes(-10);
var announcement2 = AnnouncementTestData.CreateAnnouncement(2, 42, priority: 1, effectiveFrom: DateTime.UtcNow.AddDays(-1));
announcement2.ScheduledPublishAt = DateTime.UtcNow.AddMinutes(30);
var announcements = new List<TakeoutSaaS.Domain.Tenants.Entities.TenantAnnouncement>
{
announcement1,
announcement2
};
var announcementRepository = new Mock<ITenantAnnouncementRepository>();
announcementRepository
.Setup(x => x.SearchAsync(
It.IsAny<long>(),
It.IsAny<string?>(),
It.IsAny<TakeoutSaaS.Domain.Tenants.Enums.AnnouncementStatus?>(),
It.IsAny<TakeoutSaaS.Domain.Tenants.Enums.TenantAnnouncementType?>(),
It.IsAny<bool?>(),
It.IsAny<DateTime?>(),
It.IsAny<DateTime?>(),
It.IsAny<DateTime?>(),
It.IsAny<bool>(),
It.IsAny<int?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(announcements);
// 2. (空行后) 执行
var handler = new GetTenantsAnnouncementsQueryHandler(announcementRepository.Object);
var query = new GetTenantsAnnouncementsQuery
{
TenantId = 42,
OnlyEffective = true,
Page = 1,
PageSize = 10
};
var result = await handler.Handle(query, CancellationToken.None);
// 3. (空行后) 断言
result.Items.Should().ContainSingle();
result.Items[0].Id.Should().Be(1);
}
}

View File

@@ -1,110 +0,0 @@
using FluentValidation.TestHelper;
using TakeoutSaaS.Application.App.Tenants.Validators;
using TakeoutSaaS.Application.Tests.TestUtilities;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.Tests.App.Tenants.Validators;
public sealed class CreateAnnouncementCommandValidatorTests
{
private readonly CreateAnnouncementCommandValidator _validator = new();
[Fact]
public void GivenEmptyTitle_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidCreateCommand() with { Title = "" };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Title);
}
[Fact]
public void GivenTitleTooLong_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidCreateCommand() with { Title = new string('A', 129) };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Title);
}
[Fact]
public void GivenEmptyContent_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidCreateCommand() with { Content = "" };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Content);
}
[Fact]
public void GivenEmptyTargetType_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidCreateCommand() with { TargetType = "" };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.TargetType);
}
[Fact]
public void GivenTenantIdZeroAndNotPlatform_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidCreateCommand() with
{
TenantId = 0,
PublisherScope = PublisherScope.Tenant
};
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x);
}
[Fact]
public void GivenEffectiveToBeforeFrom_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidCreateCommand() with
{
EffectiveFrom = DateTime.UtcNow,
EffectiveTo = DateTime.UtcNow.AddMinutes(-5)
};
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.EffectiveTo);
}
[Fact]
public void GivenValidCommand_WhenValidate_ThenShouldNotHaveErrors()
{
// Arrange
var command = AnnouncementTestData.CreateValidCreateCommand();
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
}

View File

@@ -1,62 +0,0 @@
using FluentValidation.TestHelper;
using TakeoutSaaS.Application.App.Tenants.Validators;
using TakeoutSaaS.Application.Tests.TestUtilities;
namespace TakeoutSaaS.Application.Tests.App.Tenants.Validators;
public sealed class PublishAnnouncementCommandValidatorTests
{
private readonly PublishAnnouncementCommandValidator _validator = new();
[Fact]
public void GivenAnnouncementIdZero_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidPublishCommand() with { AnnouncementId = 0 };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.AnnouncementId);
}
[Fact]
public void GivenNullRowVersion_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidPublishCommand() with { RowVersion = null! };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.RowVersion);
}
[Fact]
public void GivenEmptyRowVersion_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidPublishCommand() with { RowVersion = Array.Empty<byte>() };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.RowVersion);
}
[Fact]
public void GivenValidCommand_WhenValidate_ThenShouldNotHaveErrors()
{
// Arrange
var command = AnnouncementTestData.CreateValidPublishCommand();
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
}

View File

@@ -1,62 +0,0 @@
using FluentValidation.TestHelper;
using TakeoutSaaS.Application.App.Tenants.Validators;
using TakeoutSaaS.Application.Tests.TestUtilities;
namespace TakeoutSaaS.Application.Tests.App.Tenants.Validators;
public sealed class RevokeAnnouncementCommandValidatorTests
{
private readonly RevokeAnnouncementCommandValidator _validator = new();
[Fact]
public void GivenAnnouncementIdZero_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidRevokeCommand() with { AnnouncementId = 0 };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.AnnouncementId);
}
[Fact]
public void GivenNullRowVersion_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidRevokeCommand() with { RowVersion = null! };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.RowVersion);
}
[Fact]
public void GivenEmptyRowVersion_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidRevokeCommand() with { RowVersion = Array.Empty<byte>() };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.RowVersion);
}
[Fact]
public void GivenValidCommand_WhenValidate_ThenShouldNotHaveErrors()
{
// Arrange
var command = AnnouncementTestData.CreateValidRevokeCommand();
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
}

View File

@@ -1,88 +0,0 @@
using FluentValidation.TestHelper;
using TakeoutSaaS.Application.App.Tenants.Validators;
using TakeoutSaaS.Application.Tests.TestUtilities;
namespace TakeoutSaaS.Application.Tests.App.Tenants.Validators;
public sealed class UpdateAnnouncementCommandValidatorTests
{
private readonly UpdateAnnouncementCommandValidator _validator = new();
[Fact]
public void GivenEmptyTitle_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidUpdateCommand() with { Title = "" };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Title);
}
[Fact]
public void GivenTitleTooLong_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidUpdateCommand() with { Title = new string('A', 129) };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Title);
}
[Fact]
public void GivenEmptyContent_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidUpdateCommand() with { Content = "" };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Content);
}
[Fact]
public void GivenNullRowVersion_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidUpdateCommand() with { RowVersion = null! };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.RowVersion);
}
[Fact]
public void GivenEmptyRowVersion_WhenValidate_ThenShouldHaveError()
{
// Arrange
var command = AnnouncementTestData.CreateValidUpdateCommand() with { RowVersion = Array.Empty<byte>() };
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.RowVersion);
}
[Fact]
public void GivenValidCommand_WhenValidate_ThenShouldNotHaveErrors()
{
// Arrange
var command = AnnouncementTestData.CreateValidUpdateCommand();
// Act
var result = _validator.TestValidate(command);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
}

View File

@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<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>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="System.IO.Packaging" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -1,72 +0,0 @@
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.Tests.TestUtilities;
public static class AnnouncementTestData
{
public static CreateTenantAnnouncementCommand CreateValidCreateCommand()
=> new()
{
TenantId = 100,
Title = "公告标题",
Content = "公告内容",
AnnouncementType = TenantAnnouncementType.System,
Priority = 1,
EffectiveFrom = DateTime.UtcNow.AddHours(-1),
EffectiveTo = DateTime.UtcNow.AddHours(2),
PublisherScope = PublisherScope.Tenant,
TargetType = "ALL_TENANTS",
TargetParameters = null
};
public static UpdateTenantAnnouncementCommand CreateValidUpdateCommand()
=> new()
{
TenantId = 100,
AnnouncementId = 9001,
Title = "更新公告",
Content = "更新内容",
TargetType = "ALL_TENANTS",
TargetParameters = null,
RowVersion = new byte[] { 1, 2, 3 }
};
public static PublishAnnouncementCommand CreateValidPublishCommand()
=> new()
{
AnnouncementId = 9001,
RowVersion = new byte[] { 1, 2, 3 }
};
public static RevokeAnnouncementCommand CreateValidRevokeCommand()
=> new()
{
AnnouncementId = 9001,
RowVersion = new byte[] { 1, 2, 3 }
};
public static TenantAnnouncement CreateAnnouncement(
long id,
long tenantId,
int priority,
DateTime effectiveFrom,
AnnouncementStatus status = AnnouncementStatus.Draft)
=> new()
{
Id = id,
TenantId = tenantId,
Title = $"公告-{id}",
Content = "内容",
AnnouncementType = TenantAnnouncementType.System,
Priority = priority,
EffectiveFrom = effectiveFrom,
EffectiveTo = null,
PublisherScope = PublisherScope.Tenant,
Status = status,
TargetType = string.Empty,
TargetParameters = null,
RowVersion = new byte[] { 1, 1, 1 }
};
}

View File

@@ -1,608 +0,0 @@
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,
new HttpContextAccessor { HttpContext = new DefaultHttpContext() },
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,
new HttpContextAccessor { HttpContext = new DefaultHttpContext() },
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;
}
}

View File

@@ -1,98 +0,0 @@
using FluentAssertions;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Handlers;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Infrastructure.App.Repositories;
using TakeoutSaaS.Integration.Tests.Fixtures;
namespace TakeoutSaaS.Integration.Tests.App.Tenants;
public sealed class AnnouncementRegressionTests
{
[Fact]
public async Task GivenPublishedAnnouncement_WhenSearchByIsActive_ThenReturns()
{
// Arrange
using var database = new SqliteTestDatabase();
using var context = database.CreateContext(tenantId: 600, userId: 12);
var legacy = CreateAnnouncement(tenantId: 600, id: 9100);
legacy.Status = AnnouncementStatus.Published;
context.TenantAnnouncements.Add(legacy);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var repository = new EfTenantAnnouncementRepository(context);
// Act
var results = await repository.SearchAsync(
tenantId: 600,
keyword: null,
status: null,
type: null,
isActive: true,
effectiveFrom: null,
effectiveTo: null,
effectiveAt: null,
cancellationToken: CancellationToken.None);
// Assert
results.Should().ContainSingle(x => x.Id == legacy.Id);
}
[Fact]
public async Task GivenDraftAnnouncement_WhenUpdate_ThenUpdatesFieldsAndKeepsInactive()
{
// Arrange
using var database = new SqliteTestDatabase();
using var context = database.CreateContext(tenantId: 700, userId: 12);
var announcement = CreateAnnouncement(tenantId: 700, id: 9101);
announcement.Status = AnnouncementStatus.Draft;
context.TenantAnnouncements.Add(announcement);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var repository = new EfTenantAnnouncementRepository(context);
var handler = new UpdateTenantAnnouncementCommandHandler(repository);
var command = new UpdateTenantAnnouncementCommand
{
TenantId = 700,
AnnouncementId = announcement.Id,
Title = "更新后的标题",
Content = "更新后的内容",
TargetType = "ALL_TENANTS",
RowVersion = announcement.RowVersion
};
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Title.Should().Be("更新后的标题");
result.Content.Should().Be("更新后的内容");
result.IsActive.Should().BeFalse();
}
private static TenantAnnouncement CreateAnnouncement(long tenantId, long id)
=> new()
{
Id = id,
TenantId = tenantId,
Title = "旧公告",
Content = "内容",
AnnouncementType = TenantAnnouncementType.System,
Priority = 1,
EffectiveFrom = DateTime.UtcNow.AddMinutes(-5),
EffectiveTo = null,
PublisherScope = PublisherScope.Tenant,
Status = AnnouncementStatus.Draft,
TargetType = "ALL_TENANTS",
TargetParameters = null,
RowVersion = new byte[] { 1 }
};
}

View File

@@ -1,218 +0,0 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Moq;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Handlers;
using TakeoutSaaS.Application.Messaging.Abstractions;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Infrastructure.App.Repositories;
using TakeoutSaaS.Integration.Tests.Fixtures;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Integration.Tests.App.Tenants;
/// <summary>
/// 公告工作流集成测试。
/// </summary>
public sealed class AnnouncementWorkflowTests
{
[Fact]
public async Task GivenDraftAnnouncement_WhenPublish_ThenStatusIsPublishedAndActive()
{
// 1. 准备
using var database = new SqliteTestDatabase();
using var context = database.CreateContext(tenantId: 100, userId: 11);
var announcement = CreateDraftAnnouncement(tenantId: 100, id: 9001);
context.TenantAnnouncements.Add(announcement);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var repository = new EfTenantAnnouncementRepository(context);
var eventPublisher = new Mock<IEventPublisher>();
var handler = new PublishAnnouncementCommandHandler(repository, eventPublisher.Object);
// 2. (空行后) 执行
var result = await handler.Handle(new PublishAnnouncementCommand
{
TenantId = 100,
AnnouncementId = announcement.Id,
RowVersion = announcement.RowVersion
}, CancellationToken.None);
// 3. (空行后) 断言
result.Should().NotBeNull();
result!.Status.Should().Be(AnnouncementStatus.Published);
result.IsActive.Should().BeTrue();
using var verifyContext = database.CreateContext(tenantId: 100);
var persisted = await verifyContext.TenantAnnouncements.FirstAsync(x => x.Id == announcement.Id);
persisted.Status.Should().Be(AnnouncementStatus.Published);
persisted.PublishedAt.Should().NotBeNull();
}
[Fact]
public async Task GivenPublishedAnnouncement_WhenRevoke_ThenStatusIsRevokedAndInactive()
{
// 1. 准备
using var database = new SqliteTestDatabase();
using var context = database.CreateContext(tenantId: 200, userId: 11);
var announcement = CreateDraftAnnouncement(tenantId: 200, id: 9002);
announcement.Status = AnnouncementStatus.Published;
context.TenantAnnouncements.Add(announcement);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var repository = new EfTenantAnnouncementRepository(context);
var eventPublisher = new Mock<IEventPublisher>();
var handler = new RevokeAnnouncementCommandHandler(repository, eventPublisher.Object);
// 2. (空行后) 执行
var result = await handler.Handle(new RevokeAnnouncementCommand
{
TenantId = 200,
AnnouncementId = announcement.Id,
RowVersion = announcement.RowVersion
}, CancellationToken.None);
// 3. (空行后) 断言
result.Should().NotBeNull();
result!.Status.Should().Be(AnnouncementStatus.Revoked);
result.IsActive.Should().BeFalse();
using var verifyContext = database.CreateContext(tenantId: 200);
var persisted = await verifyContext.TenantAnnouncements.FirstAsync(x => x.Id == announcement.Id);
persisted.Status.Should().Be(AnnouncementStatus.Revoked);
persisted.RevokedAt.Should().NotBeNull();
}
[Fact]
public async Task GivenRevokedAnnouncement_WhenPublish_ThenRepublishAndClearRevokedAt()
{
// 1. 准备
using var database = new SqliteTestDatabase();
using var context = database.CreateContext(tenantId: 300, userId: 11);
var announcement = CreateDraftAnnouncement(tenantId: 300, id: 9003);
announcement.Status = AnnouncementStatus.Revoked;
announcement.RevokedAt = DateTime.UtcNow.AddMinutes(-5);
context.TenantAnnouncements.Add(announcement);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var repository = new EfTenantAnnouncementRepository(context);
var eventPublisher = new Mock<IEventPublisher>();
var handler = new PublishAnnouncementCommandHandler(repository, eventPublisher.Object);
// 2. (空行后) 执行
var result = await handler.Handle(new PublishAnnouncementCommand
{
TenantId = 300,
AnnouncementId = announcement.Id,
RowVersion = announcement.RowVersion
}, CancellationToken.None);
// 3. (空行后) 断言
result.Should().NotBeNull();
result!.Status.Should().Be(AnnouncementStatus.Published);
result.IsActive.Should().BeTrue();
using var verifyContext = database.CreateContext(tenantId: 300);
var persisted = await verifyContext.TenantAnnouncements.FirstAsync(x => x.Id == announcement.Id);
persisted.Status.Should().Be(AnnouncementStatus.Published);
persisted.RevokedAt.Should().BeNull();
}
[Fact]
public async Task GivenPublishedAnnouncement_WhenUpdate_ThenThrowsBusinessException()
{
// Arrange
using var database = new SqliteTestDatabase();
using var context = database.CreateContext(tenantId: 400, userId: 11);
var announcement = CreateDraftAnnouncement(tenantId: 400, id: 9004);
announcement.Status = AnnouncementStatus.Published;
context.TenantAnnouncements.Add(announcement);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var repository = new EfTenantAnnouncementRepository(context);
var handler = new UpdateTenantAnnouncementCommandHandler(repository);
var command = new UpdateTenantAnnouncementCommand
{
TenantId = 400,
AnnouncementId = announcement.Id,
Title = "更新标题",
Content = "更新内容",
TargetType = "ALL_TENANTS",
RowVersion = announcement.RowVersion
};
// Act
Func<Task> act = async () => await handler.Handle(command, CancellationToken.None);
// Assert
var exception = await act.Should().ThrowAsync<BusinessException>();
exception.Which.ErrorCode.Should().Be(ErrorCodes.Conflict);
}
[Fact]
public async Task GivenStaleRowVersion_WhenUpdate_ThenReturnsConflict()
{
// Arrange
using var database = new SqliteTestDatabase();
using var context = database.CreateContext(tenantId: 500, userId: 11);
var announcement = CreateDraftAnnouncement(tenantId: 500, id: 9005);
announcement.RowVersion = new byte[] { 1 };
context.TenantAnnouncements.Add(announcement);
await context.SaveChangesAsync();
await context.Database.ExecuteSqlRawAsync(
"UPDATE tenant_announcements SET \"RowVersion\" = {0} WHERE \"Id\" = {1}",
new byte[] { 9 }, announcement.Id);
context.ChangeTracker.Clear();
var repository = new EfTenantAnnouncementRepository(context);
var handler = new UpdateTenantAnnouncementCommandHandler(repository);
var command = new UpdateTenantAnnouncementCommand
{
TenantId = 500,
AnnouncementId = announcement.Id,
Title = "并发更新",
Content = "内容",
TargetType = "ALL_TENANTS",
RowVersion = new byte[] { 1 }
};
// Act
Func<Task> act = async () => await handler.Handle(command, CancellationToken.None);
// Assert
var exception = await act.Should().ThrowAsync<BusinessException>();
exception.Which.ErrorCode.Should().Be(ErrorCodes.Conflict);
}
private static TenantAnnouncement CreateDraftAnnouncement(long tenantId, long id)
=> new()
{
Id = id,
TenantId = tenantId,
Title = "公告",
Content = "内容",
AnnouncementType = TenantAnnouncementType.System,
Priority = 1,
EffectiveFrom = DateTime.UtcNow.AddMinutes(-10),
EffectiveTo = DateTime.UtcNow.AddMinutes(30),
PublisherScope = PublisherScope.Tenant,
Status = AnnouncementStatus.Draft,
TargetType = "ALL_TENANTS",
TargetParameters = null,
RowVersion = new byte[] { 1 }
};
}

View File

@@ -1,58 +0,0 @@
using FluentAssertions;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Infrastructure.App.Repositories;
using TakeoutSaaS.Integration.Tests.Fixtures;
namespace TakeoutSaaS.Integration.Tests.App.Tenants;
public sealed class TenantAnnouncementRepositoryScopeTests
{
[Fact]
public async Task GivenTenantAndPlatformAnnouncements_WhenSearchAsync_ThenReturnsBoth()
{
// Arrange
using var database = new SqliteTestDatabase();
using var context = database.CreateContext(tenantId: 800);
var tenantAnnouncement = CreateAnnouncement(tenantId: 800, id: 9200);
var platformAnnouncement = CreateAnnouncement(tenantId: 0, id: 9201);
context.TenantAnnouncements.AddRange(tenantAnnouncement, platformAnnouncement);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var repository = new EfTenantAnnouncementRepository(context);
// Act
var results = await repository.SearchAsync(
tenantId: 800,
keyword: null,
status: null,
type: null,
isActive: null,
effectiveFrom: null,
effectiveTo: null,
effectiveAt: null,
cancellationToken: CancellationToken.None);
// Assert
results.Select(x => x.Id).Should().Contain(new[] { tenantAnnouncement.Id, platformAnnouncement.Id });
}
private static TenantAnnouncement CreateAnnouncement(long tenantId, long id)
=> new()
{
Id = id,
TenantId = tenantId,
Title = "公告",
Content = "内容",
AnnouncementType = TenantAnnouncementType.System,
Priority = 1,
EffectiveFrom = DateTime.UtcNow.AddMinutes(-5),
PublisherScope = PublisherScope.Tenant,
Status = AnnouncementStatus.Draft,
TargetType = "ALL_TENANTS",
RowVersion = new byte[] { 1 }
};
}

View File

@@ -1,80 +0,0 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Module.Authorization.Policies;
namespace TakeoutSaaS.Integration.Tests.Authorization;
public sealed class PermissionAuthorizationHandlerTests
{
[Theory]
[InlineData("platform-announcement:create")]
[InlineData("platform-announcement:publish")]
[InlineData("platform-announcement:revoke")]
[InlineData("tenant-announcement:publish")]
[InlineData("tenant-announcement:revoke")]
public async Task GivenUserWithPermission_WhenAuthorize_ThenSucceeds(string permission)
{
// Arrange
var requirement = new PermissionRequirement(new[] { permission });
var identity = new ClaimsIdentity(new[]
{
new Claim(PermissionAuthorizationHandler.PermissionClaimType, permission)
}, "Test");
var user = new ClaimsPrincipal(identity);
var context = new AuthorizationHandlerContext(new[] { requirement }, user, null);
var handler = new PermissionAuthorizationHandler();
// Act
await handler.HandleAsync(context);
// Assert
context.HasSucceeded.Should().BeTrue();
}
[Fact]
public async Task GivenUserWithoutPermission_WhenAuthorize_ThenFails()
{
// Arrange
var requirement = new PermissionRequirement(new[] { "platform-announcement:create" });
var user = new ClaimsPrincipal(new ClaimsIdentity(authenticationType: "Test"));
var context = new AuthorizationHandlerContext(new[] { requirement }, user, null);
var handler = new PermissionAuthorizationHandler();
// Act
await handler.HandleAsync(context);
// Assert
context.HasSucceeded.Should().BeFalse();
}
[Fact]
public async Task GivenAuthenticatedUserWithoutPermission_WhenEvaluatingPolicy_ThenForbidden()
{
// Arrange
var services = new ServiceCollection();
services.AddAuthorization();
services.AddSingleton<IAuthorizationPolicyProvider, PermissionAuthorizationPolicyProvider>();
services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
var provider = services.BuildServiceProvider();
var policyProvider = provider.GetRequiredService<IAuthorizationPolicyProvider>();
var policy = await policyProvider.GetPolicyAsync(
PermissionAuthorizationPolicyProvider.BuildPolicyName(new[] { "platform-announcement:create" }));
var user = new ClaimsPrincipal(new ClaimsIdentity(authenticationType: "Test"));
var authenticateResult = AuthenticateResult.Success(new AuthenticationTicket(user, "Test"));
var httpContext = new DefaultHttpContext { RequestServices = provider, User = user };
var evaluator = provider.GetRequiredService<IPolicyEvaluator>();
// Act
var result = await evaluator.AuthorizeAsync(policy!, authenticateResult, httpContext, resource: null);
// Assert
result.Forbidden.Should().BeTrue();
}
}

View File

@@ -1,58 +0,0 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Integration.Tests.Fixtures;
/// <summary>
/// 集成测试用 SQLite 内存数据库(字典库)。
/// </summary>
public sealed class DictionarySqliteTestDatabase : IDisposable
{
private readonly SqliteConnection _connection;
private readonly TestIdGenerator _idGenerator = new();
private bool _initialized;
public DictionarySqliteTestDatabase()
{
_connection = new SqliteConnection("Filename=:memory:");
_connection.Open();
Options = new DbContextOptionsBuilder<DictionaryDbContext>()
.UseSqlite(_connection)
.EnableSensitiveDataLogging()
.Options;
}
public DbContextOptions<DictionaryDbContext> Options { get; }
public DictionaryDbContext CreateContext(long tenantId, long userId = 0)
{
EnsureCreated();
// 1. AdminApi 不使用租户上下文tenantId 参数仅用于兼容测试调用方签名
_ = tenantId;
// 2. (空行后) 按需注入当前用户与 ID 生成器
return new DictionaryDbContext(
Options,
userId == 0 ? null : new TestCurrentUserAccessor(userId),
_idGenerator);
}
public void EnsureCreated()
{
if (_initialized)
{
return;
}
// 1. 创建并初始化数据库结构
using var context = new DictionaryDbContext(Options, idGenerator: _idGenerator);
context.Database.EnsureCreated();
_initialized = true;
}
public void Dispose()
{
_connection.Dispose();
}
}

View File

@@ -1,58 +0,0 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Integration.Tests.Fixtures;
/// <summary>
/// 集成测试用 SQLite 内存数据库(业务主库)。
/// </summary>
public sealed class SqliteTestDatabase : IDisposable
{
private readonly SqliteConnection _connection;
private readonly TestIdGenerator _idGenerator = new();
private bool _initialized;
public SqliteTestDatabase()
{
_connection = new SqliteConnection("Filename=:memory:");
_connection.Open();
Options = new DbContextOptionsBuilder<TakeoutAdminDbContext>()
.UseSqlite(_connection)
.EnableSensitiveDataLogging()
.Options;
}
public DbContextOptions<TakeoutAdminDbContext> Options { get; }
public TakeoutAdminDbContext CreateContext(long tenantId, long userId = 0)
{
EnsureCreated();
// 1. AdminApi 不使用租户上下文tenantId 参数仅用于兼容测试调用方签名
_ = tenantId;
// 2. (空行后) 按需注入当前用户与 ID 生成器
return new TakeoutAdminDbContext(
Options,
userId == 0 ? null : new TestCurrentUserAccessor(userId),
_idGenerator);
}
public void EnsureCreated()
{
if (_initialized)
{
return;
}
// 1. 创建并初始化数据库结构
using var context = new TakeoutAdminDbContext(Options, idGenerator: _idGenerator);
context.Database.EnsureCreated();
_initialized = true;
}
public void Dispose()
{
_connection.Dispose();
}
}

View File

@@ -1,10 +0,0 @@
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Integration.Tests.Fixtures;
public sealed class TestCurrentUserAccessor(long userId) : ICurrentUserAccessor
{
public long UserId { get; set; } = userId;
public bool IsAuthenticated => UserId != 0;
}

View File

@@ -1,46 +0,0 @@
using System.Collections.Concurrent;
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Domain.Dictionary.Enums;
namespace TakeoutSaaS.Integration.Tests.Fixtures;
public sealed class TestDictionaryHybridCache : IDictionaryHybridCache
{
private readonly ConcurrentDictionary<string, object> _cache = new(StringComparer.OrdinalIgnoreCase);
public async Task<T?> GetOrCreateAsync<T>(
string key,
TimeSpan ttl,
Func<CancellationToken, Task<T?>> factory,
CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(key, out var cached) && cached is T typed)
{
return typed;
}
var value = await factory(cancellationToken);
if (value is not null)
{
_cache[key] = value;
}
return value;
}
public Task InvalidateAsync(
string prefix,
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
CancellationToken cancellationToken = default)
{
foreach (var key in _cache.Keys)
{
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
_cache.TryRemove(key, out _);
}
}
return Task.CompletedTask;
}
}

View File

@@ -1,20 +0,0 @@
using System.Threading;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Integration.Tests.Fixtures;
/// <summary>
/// 集成测试用雪花 ID 生成器(递增模拟)。
/// </summary>
public sealed class TestIdGenerator : IIdGenerator
{
private long _current;
/// <summary>
/// 生成下一个 ID。
/// </summary>
/// <returns>递增的 long ID。</returns>
public long NextId()
=> Interlocked.Increment(ref _current);
}

View File

@@ -1,10 +0,0 @@
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Integration.Tests.Fixtures;
public sealed class TestTenantProvider(long tenantId) : ITenantProvider
{
public long TenantId { get; set; } = tenantId;
public long GetCurrentTenantId() => TenantId;
}

View File

@@ -1,70 +0,0 @@
using System.Diagnostics;
using FluentAssertions;
using TakeoutSaaS.Application.App.Tenants.Handlers;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Infrastructure.App.Repositories;
using TakeoutSaaS.Integration.Tests.Fixtures;
namespace TakeoutSaaS.Integration.Tests.Performance;
/// <summary>
/// 公告查询性能相关测试。
/// </summary>
public sealed class AnnouncementQueryPerformanceTests
{
[Fact]
public async Task GivenLargeDataset_WhenQueryingAnnouncements_ThenCompletesWithinThreshold()
{
// 1. 准备
using var database = new SqliteTestDatabase();
using var context = database.CreateContext(tenantId: 900);
var announcements = new List<TenantAnnouncement>();
for (var i = 0; i < 1000; i++)
{
var tenantId = i % 2 == 0 ? 900 : 0;
var targetType = i % 10 == 0 ? "ROLES" : "ALL_TENANTS";
var targetParameters = i % 10 == 0 ? "{\"roles\":[\"ops\"]}" : null;
announcements.Add(new TenantAnnouncement
{
Id = 10000 + i,
TenantId = tenantId,
Title = "公告",
Content = "内容",
AnnouncementType = TenantAnnouncementType.System,
Priority = i % 5,
EffectiveFrom = DateTime.UtcNow.AddDays(-1),
PublisherScope = tenantId == 0 ? PublisherScope.Platform : PublisherScope.Tenant,
Status = AnnouncementStatus.Published,
TargetType = targetType,
TargetParameters = targetParameters,
RowVersion = new byte[] { 1 }
});
}
context.TenantAnnouncements.AddRange(announcements);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var announcementRepository = new EfTenantAnnouncementRepository(context);
var handler = new GetTenantsAnnouncementsQueryHandler(announcementRepository);
var query = new GetTenantsAnnouncementsQuery
{
TenantId = 900,
Page = 1,
PageSize = 50
};
// 2. (空行后) 执行
var stopwatch = Stopwatch.StartNew();
var result = await handler.Handle(query, CancellationToken.None);
stopwatch.Stop();
// 3. (空行后) 断言TotalCount 为估算口径page * size * 3过滤后的数量
result.Items.Count.Should().Be(50); // 请求的页大小
result.TotalCount.Should().BeLessThanOrEqualTo(150); // 最多是 estimatedLimit
result.TotalCount.Should().BeGreaterThan(0); // 至少有一些结果
stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(5));
}
}

View File

@@ -1,39 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<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>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
<ProjectReference Include="..\..\src\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
<ProjectReference Include="..\..\src\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="System.IO.Packaging" Version="10.0.1" />
</ItemGroup>
</Project>