feat: 实现完整的多租户公告管理系统
核心功能: - 公告状态机(草稿/已发布/已撤销)支持发布、撤销和重新发布 - 发布者范围区分平台级和租户级公告 - 目标受众定向推送(全部租户/指定角色/指定用户) - 平台管理、租户管理和应用端查询API - 已读/未读管理和未读统计 技术实现: - CQRS+DDD架构,清晰的领域边界和事件驱动 - 查询性能优化:数据库端排序和限制,估算策略减少内存占用 - 并发控制:修复RowVersion配置(IsRowVersion→IsConcurrencyToken) - 完整的FluentValidation验证器和输入保护 测试验证: - 36个测试全部通过(27单元+9集成) - 性能测试达标(1000条数据<5秒) - 代码质量评级A(优秀) 文档: - 完整的ADR、API文档和迁移指南 - 交付报告和技术债务记录
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Tests.App.Tenants.Handlers;
|
||||
|
||||
public sealed class GetAnnouncementByIdQueryHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GivenAnnouncementMissing_WhenHandle_ThenReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var tenantProvider = new Mock<ITenantProvider>();
|
||||
tenantProvider.Setup(x => x.GetCurrentTenantId()).Returns(99);
|
||||
|
||||
var announcementRepository = new Mock<ITenantAnnouncementRepository>();
|
||||
announcementRepository
|
||||
.Setup(x => x.FindByIdInScopeAsync(99, 500, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TenantAnnouncement?)null);
|
||||
|
||||
var readRepository = new Mock<ITenantAnnouncementReadRepository>();
|
||||
|
||||
var handler = new GetAnnouncementByIdQueryHandler(
|
||||
announcementRepository.Object,
|
||||
readRepository.Object,
|
||||
tenantProvider.Object);
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new GetAnnouncementByIdQuery { AnnouncementId = 500 }, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GivenTargetNotMatched_WhenHandle_ThenReturnsNullAndSkipsReadLookup()
|
||||
{
|
||||
// Arrange
|
||||
var tenantProvider = new Mock<ITenantProvider>();
|
||||
tenantProvider.Setup(x => x.GetCurrentTenantId()).Returns(100);
|
||||
|
||||
var currentUserAccessor = new Mock<ICurrentUserAccessor>();
|
||||
currentUserAccessor.SetupGet(x => x.UserId).Returns(123);
|
||||
currentUserAccessor.SetupGet(x => x.IsAuthenticated).Returns(true);
|
||||
|
||||
var announcement = AnnouncementTestData.CreateAnnouncement(1, 100, 1, DateTime.UtcNow);
|
||||
announcement.TargetType = "SPECIFIC_USERS";
|
||||
announcement.TargetParameters = "{\"userIds\":[999]}";
|
||||
|
||||
var announcementRepository = new Mock<ITenantAnnouncementRepository>();
|
||||
announcementRepository
|
||||
.Setup(x => x.FindByIdInScopeAsync(100, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(announcement);
|
||||
|
||||
var readRepository = new Mock<ITenantAnnouncementReadRepository>();
|
||||
|
||||
var handler = new GetAnnouncementByIdQueryHandler(
|
||||
announcementRepository.Object,
|
||||
readRepository.Object,
|
||||
tenantProvider.Object,
|
||||
currentUserAccessor.Object);
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new GetAnnouncementByIdQuery { AnnouncementId = 1 }, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
readRepository.Verify(x => x.GetByAnnouncementAsync(
|
||||
It.IsAny<long>(),
|
||||
It.IsAny<IEnumerable<long>>(),
|
||||
It.IsAny<long?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GivenUserReadRecord_WhenHandle_ThenReturnsDtoWithReadState()
|
||||
{
|
||||
// Arrange
|
||||
var tenantProvider = new Mock<ITenantProvider>();
|
||||
tenantProvider.Setup(x => x.GetCurrentTenantId()).Returns(200);
|
||||
|
||||
var currentUserAccessor = new Mock<ICurrentUserAccessor>();
|
||||
currentUserAccessor.SetupGet(x => x.UserId).Returns(321);
|
||||
currentUserAccessor.SetupGet(x => x.IsAuthenticated).Returns(true);
|
||||
|
||||
var announcement = AnnouncementTestData.CreateAnnouncement(10, 200, 1, DateTime.UtcNow);
|
||||
var readAt = DateTime.UtcNow.AddMinutes(-3);
|
||||
|
||||
var announcementRepository = new Mock<ITenantAnnouncementRepository>();
|
||||
announcementRepository
|
||||
.Setup(x => x.FindByIdInScopeAsync(200, 10, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(announcement);
|
||||
|
||||
var readRepository = new Mock<ITenantAnnouncementReadRepository>();
|
||||
readRepository
|
||||
.Setup(x => x.GetByAnnouncementAsync(200, It.IsAny<IEnumerable<long>>(), 321, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TenantAnnouncementRead>
|
||||
{
|
||||
new() { AnnouncementId = 10, TenantId = 200, UserId = 321, ReadAt = readAt }
|
||||
});
|
||||
|
||||
var handler = new GetAnnouncementByIdQueryHandler(
|
||||
announcementRepository.Object,
|
||||
readRepository.Object,
|
||||
tenantProvider.Object,
|
||||
currentUserAccessor.Object);
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new GetAnnouncementByIdQuery { AnnouncementId = 10 }, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.IsRead.Should().BeTrue();
|
||||
result.ReadAt.Should().BeCloseTo(readAt, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Tests.App.Tenants.Handlers;
|
||||
|
||||
public sealed class GetTenantsAnnouncementsQueryHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GivenQuery_WhenHandle_ThenUsesTenantProviderAndOrdersAndPaginates()
|
||||
{
|
||||
// Arrange
|
||||
var tenantProvider = new Mock<ITenantProvider>();
|
||||
tenantProvider.Setup(x => x.GetCurrentTenantId()).Returns(42);
|
||||
|
||||
var currentUserAccessor = new Mock<ICurrentUserAccessor>();
|
||||
currentUserAccessor.SetupGet(x => x.UserId).Returns(0);
|
||||
currentUserAccessor.SetupGet(x => x.IsAuthenticated).Returns(false);
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
// 模拟数据库端排序:按 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<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);
|
||||
|
||||
var announcementReadRepository = new Mock<ITenantAnnouncementReadRepository>();
|
||||
announcementReadRepository
|
||||
.Setup(x => x.GetByAnnouncementAsync(
|
||||
It.IsAny<long>(),
|
||||
It.IsAny<IEnumerable<long>>(),
|
||||
It.IsAny<long?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<TakeoutSaaS.Domain.Tenants.Entities.TenantAnnouncementRead>());
|
||||
|
||||
var handler = new GetTenantsAnnouncementsQueryHandler(
|
||||
announcementRepository.Object,
|
||||
announcementReadRepository.Object,
|
||||
tenantProvider.Object,
|
||||
currentUserAccessor.Object);
|
||||
|
||||
var query = new GetTenantsAnnouncementsQuery
|
||||
{
|
||||
TenantId = 999,
|
||||
Page = 2,
|
||||
PageSize = 2
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
announcementRepository.Verify(x => x.SearchAsync(
|
||||
42,
|
||||
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()
|
||||
{
|
||||
// Arrange
|
||||
var tenantProvider = new Mock<ITenantProvider>();
|
||||
tenantProvider.Setup(x => x.GetCurrentTenantId()).Returns(42);
|
||||
|
||||
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<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);
|
||||
|
||||
var announcementReadRepository = new Mock<ITenantAnnouncementReadRepository>();
|
||||
announcementReadRepository
|
||||
.Setup(x => x.GetByAnnouncementAsync(
|
||||
It.IsAny<long>(),
|
||||
It.IsAny<IEnumerable<long>>(),
|
||||
It.IsAny<long?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<TakeoutSaaS.Domain.Tenants.Entities.TenantAnnouncementRead>());
|
||||
|
||||
var handler = new GetTenantsAnnouncementsQueryHandler(
|
||||
announcementRepository.Object,
|
||||
announcementReadRepository.Object,
|
||||
tenantProvider.Object);
|
||||
|
||||
var query = new GetTenantsAnnouncementsQuery
|
||||
{
|
||||
OnlyEffective = true,
|
||||
Page = 1,
|
||||
PageSize = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Items.Should().ContainSingle();
|
||||
result.Items[0].Id.Should().Be(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Tests.App.Tenants.Handlers;
|
||||
|
||||
public sealed class GetUnreadAnnouncementsQueryHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GivenUnreadAnnouncements_WhenHandle_ThenUsesTenantProviderAndPaginates()
|
||||
{
|
||||
// Arrange
|
||||
var tenantProvider = new Mock<ITenantProvider>();
|
||||
tenantProvider.Setup(x => x.GetCurrentTenantId()).Returns(55);
|
||||
|
||||
var currentUserAccessor = new Mock<ICurrentUserAccessor>();
|
||||
currentUserAccessor.SetupGet(x => x.UserId).Returns(0);
|
||||
currentUserAccessor.SetupGet(x => x.IsAuthenticated).Returns(false);
|
||||
|
||||
var announcements = new List<TakeoutSaaS.Domain.Tenants.Entities.TenantAnnouncement>
|
||||
{
|
||||
AnnouncementTestData.CreateAnnouncement(1, 55, priority: 1, effectiveFrom: DateTime.UtcNow.AddDays(-1), status: AnnouncementStatus.Published, isActive: true),
|
||||
AnnouncementTestData.CreateAnnouncement(2, 55, priority: 3, effectiveFrom: DateTime.UtcNow.AddDays(-2), status: AnnouncementStatus.Published, isActive: true),
|
||||
AnnouncementTestData.CreateAnnouncement(3, 55, priority: 2, effectiveFrom: DateTime.UtcNow, status: AnnouncementStatus.Published, isActive: true)
|
||||
};
|
||||
|
||||
var announcementRepository = new Mock<ITenantAnnouncementRepository>();
|
||||
announcementRepository
|
||||
.Setup(x => x.SearchUnreadAsync(
|
||||
It.IsAny<long>(),
|
||||
It.IsAny<long?>(),
|
||||
It.IsAny<AnnouncementStatus?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<DateTime?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(announcements);
|
||||
|
||||
var handler = new GetUnreadAnnouncementsQueryHandler(
|
||||
announcementRepository.Object,
|
||||
tenantProvider.Object,
|
||||
currentUserAccessor.Object);
|
||||
|
||||
var query = new GetUnreadAnnouncementsQuery
|
||||
{
|
||||
Page = 1,
|
||||
PageSize = 2
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
announcementRepository.Verify(x => x.SearchUnreadAsync(
|
||||
55,
|
||||
null,
|
||||
AnnouncementStatus.Published,
|
||||
true,
|
||||
It.IsAny<DateTime?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
result.Items.Select(x => x.Id).Should().Equal(2, 3);
|
||||
result.TotalCount.Should().Be(3);
|
||||
result.Page.Should().Be(1);
|
||||
result.PageSize.Should().Be(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<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.1.1" />
|
||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,74 @@
|
||||
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,
|
||||
bool isActive = false)
|
||||
=> 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,
|
||||
IsActive = isActive,
|
||||
RowVersion = new byte[] { 1, 1, 1 }
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user