using FsCheck;
using FsCheck.Xunit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MiAssessment.Admin.Business.Data;
using MiAssessment.Admin.Business.Entities;
using MiAssessment.Admin.Business.Models;
using MiAssessment.Admin.Business.Models.Common;
using MiAssessment.Admin.Business.Models.Content;
using MiAssessment.Admin.Business.Services;
using Moq;
using Xunit;
namespace MiAssessment.Tests.Admin;
///
/// Banner 属性测试
/// 验证轮播图服务的正确性属性
///
public class BannerPropertyTests
{
private readonly Mock> _mockLogger = new();
#region Property 1: Soft Delete Behavior
///
/// Property 1: 软删除后实体仍存在于数据库中
/// *For any* entity that supports soft delete, when a delete operation is performed,
/// the entity's IsDeleted field SHALL be set to true and the entity SHALL remain in the database.
///
/// **Validates: Requirements 2.7**
///
[Property(MaxTest = 100)]
public bool SoftDeleteBehavior_EntityRemainsInDatabase(PositiveInt seed)
{
// Arrange: 创建包含轮播图的数据库
using var dbContext = CreateDbContext();
var bannerId = CreateTestBanner(dbContext, seed.Get);
var service = new ContentService(dbContext, _mockLogger.Object);
// Act: 执行删除操作
var result = service.DeleteBannerAsync(bannerId).GetAwaiter().GetResult();
// Assert: 实体应该仍然存在于数据库中,且 IsDeleted = true
var deletedBanner = dbContext.Banners.FirstOrDefault(b => b.Id == bannerId);
return result
&& deletedBanner != null
&& deletedBanner.IsDeleted;
}
///
/// Property 1: 软删除后的实体不应出现在列表查询中
/// *For any* deleted entity, it SHALL NOT appear in list queries.
///
/// **Validates: Requirements 2.7**
///
[Property(MaxTest = 100)]
public bool SoftDeleteBehavior_DeletedEntityNotInListQuery(PositiveInt seed)
{
// Arrange: 创建多个轮播图
using var dbContext = CreateDbContext();
var bannerId1 = CreateTestBanner(dbContext, seed.Get);
var bannerId2 = CreateTestBanner(dbContext, seed.Get + 1000);
var service = new ContentService(dbContext, _mockLogger.Object);
// Act: 删除其中一个轮播图
service.DeleteBannerAsync(bannerId1).GetAwaiter().GetResult();
// 查询列表
var request = new BannerQueryRequest { Page = 1, PageSize = 100 };
var result = service.GetBannerListAsync(request).GetAwaiter().GetResult();
// Assert: 删除的轮播图不应出现在列表中
var deletedBannerInList = result.List.Any(b => b.Id == bannerId1);
var activeBannerInList = result.List.Any(b => b.Id == bannerId2);
return !deletedBannerInList && activeBannerInList;
}
///
/// Property 1: 软删除后 UpdateTime 应该被更新
///
/// **Validates: Requirements 2.7**
///
[Property(MaxTest = 50)]
public bool SoftDeleteBehavior_UpdateTimeIsSet(PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var originalUpdateTime = DateTime.Now.AddDays(-1);
var banner = new Banner
{
Title = $"Test Banner {seed.Get}",
ImageUrl = $"https://example.com/image_{seed.Get}.jpg",
LinkType = 0,
Sort = seed.Get % 100,
Status = 1,
CreateTime = originalUpdateTime,
UpdateTime = originalUpdateTime,
IsDeleted = false
};
dbContext.Banners.Add(banner);
dbContext.SaveChanges();
var service = new ContentService(dbContext, _mockLogger.Object);
var beforeDelete = DateTime.Now;
// Act: 执行删除操作
service.DeleteBannerAsync(banner.Id).GetAwaiter().GetResult();
var afterDelete = DateTime.Now;
// Assert: UpdateTime 应该在删除前后的时间范围内
var deletedBanner = dbContext.Banners.First(b => b.Id == banner.Id);
return deletedBanner.UpdateTime >= beforeDelete
&& deletedBanner.UpdateTime <= afterDelete
&& deletedBanner.UpdateTime > originalUpdateTime;
}
#endregion
#region Property 4: Sorting Correctness
///
/// Property 4: 列表按 Sort 字段降序排列
/// *For any* list query with a Sort field, the returned items SHALL be ordered
/// by the Sort field in descending order.
///
/// **Validates: Requirements 2.1**
///
[Property(MaxTest = 100)]
public bool SortingCorrectness_ListOrderedBySortDescending(PositiveInt seed)
{
// Arrange: 创建多个具有不同 Sort 值的轮播图
using var dbContext = CreateDbContext();
var sortValues = GenerateSortValues(seed.Get, 5);
foreach (var sortValue in sortValues)
{
var banner = new Banner
{
Title = $"Banner Sort {sortValue}",
ImageUrl = $"https://example.com/image_{sortValue}.jpg",
LinkType = 0,
Sort = sortValue,
Status = 1,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now,
IsDeleted = false
};
dbContext.Banners.Add(banner);
}
dbContext.SaveChanges();
var service = new ContentService(dbContext, _mockLogger.Object);
// Act: 查询列表
var request = new BannerQueryRequest { Page = 1, PageSize = 100 };
var result = service.GetBannerListAsync(request).GetAwaiter().GetResult();
// Assert: 列表应该按 Sort 降序排列
if (result.List.Count < 2) return true;
for (int i = 0; i < result.List.Count - 1; i++)
{
if (result.List[i].Sort < result.List[i + 1].Sort)
{
return false;
}
}
return true;
}
///
/// Property 4: 相同 Sort 值时按 CreateTime 降序排列
///
/// **Validates: Requirements 2.1**
///
[Property(MaxTest = 50)]
public bool SortingCorrectness_SameSortOrderedByCreateTimeDescending(PositiveInt seed)
{
// Arrange: 创建多个具有相同 Sort 值但不同 CreateTime 的轮播图
using var dbContext = CreateDbContext();
var sameSort = seed.Get % 100;
var baseTime = DateTime.Now.AddDays(-10);
for (int i = 0; i < 3; i++)
{
var banner = new Banner
{
Title = $"Banner {i}",
ImageUrl = $"https://example.com/image_{i}.jpg",
LinkType = 0,
Sort = sameSort,
Status = 1,
CreateTime = baseTime.AddDays(i), // 不同的创建时间
UpdateTime = DateTime.Now,
IsDeleted = false
};
dbContext.Banners.Add(banner);
}
dbContext.SaveChanges();
var service = new ContentService(dbContext, _mockLogger.Object);
// Act: 查询列表
var request = new BannerQueryRequest { Page = 1, PageSize = 100 };
var result = service.GetBannerListAsync(request).GetAwaiter().GetResult();
// Assert: 相同 Sort 值时应该按 CreateTime 降序排列
var sameSortBanners = result.List.Where(b => b.Sort == sameSort).ToList();
if (sameSortBanners.Count < 2) return true;
for (int i = 0; i < sameSortBanners.Count - 1; i++)
{
if (sameSortBanners[i].CreateTime < sameSortBanners[i + 1].CreateTime)
{
return false;
}
}
return true;
}
///
/// Property 4: 排序更新后列表顺序应该正确
///
/// **Validates: Requirements 2.1**
///
[Property(MaxTest = 50)]
public bool SortingCorrectness_AfterSortUpdate_ListOrderCorrect(PositiveInt seed)
{
// Arrange: 创建轮播图
using var dbContext = CreateDbContext();
var banner1 = CreateTestBannerWithSort(dbContext, seed.Get, 10);
var banner2 = CreateTestBannerWithSort(dbContext, seed.Get + 1, 20);
var banner3 = CreateTestBannerWithSort(dbContext, seed.Get + 2, 30);
var service = new ContentService(dbContext, _mockLogger.Object);
// Act: 更新排序,将 banner1 的 Sort 改为最大
var sortItems = new List
{
new SortItem { Id = banner1, Sort = 100 }
};
service.UpdateBannerSortAsync(sortItems).GetAwaiter().GetResult();
// 查询列表
var request = new BannerQueryRequest { Page = 1, PageSize = 100 };
var result = service.GetBannerListAsync(request).GetAwaiter().GetResult();
// Assert: banner1 应该排在第一位
return result.List.Count > 0 && result.List[0].Id == banner1;
}
#endregion
#region Property 7: Banner Link Type Validation
///
/// Property 7: LinkType 1 或 2 时 LinkUrl 必填
/// *For any* Banner with LinkType 1 or 2, the LinkUrl SHALL be non-empty.
///
/// **Validates: Requirements 2.4**
///
[Property(MaxTest = 100)]
public bool LinkTypeValidation_LinkUrlRequiredForType1And2(PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = new ContentService(dbContext, _mockLogger.Object);
// 测试 LinkType 1 (内部页面) 和 2 (外部链接)
var linkType = (seed.Get % 2) + 1; // 1 或 2
var request = new CreateBannerRequest
{
Title = $"Test Banner {seed.Get}",
ImageUrl = $"https://example.com/image_{seed.Get}.jpg",
LinkType = linkType,
LinkUrl = null, // 空的 LinkUrl
Sort = seed.Get % 100,
Status = 1
};
// Act & Assert: 应该抛出 BusinessException
try
{
service.CreateBannerAsync(request).GetAwaiter().GetResult();
return false; // 应该抛出异常
}
catch (BusinessException ex)
{
return ex.Code == ErrorCodes.BannerLinkUrlRequired;
}
}
///
/// Property 7: LinkType 1 或 2 时提供有效 LinkUrl 应该成功
///
/// **Validates: Requirements 2.4**
///
[Property(MaxTest = 100)]
public bool LinkTypeValidation_ValidLinkUrlForType1And2_Success(PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = new ContentService(dbContext, _mockLogger.Object);
var linkType = (seed.Get % 2) + 1; // 1 或 2
var request = new CreateBannerRequest
{
Title = $"Test Banner {seed.Get}",
ImageUrl = $"https://example.com/image_{seed.Get}.jpg",
LinkType = linkType,
LinkUrl = $"https://example.com/page_{seed.Get}", // 有效的 LinkUrl
Sort = seed.Get % 100,
Status = 1
};
// Act
var bannerId = service.CreateBannerAsync(request).GetAwaiter().GetResult();
// Assert: 应该创建成功
var banner = dbContext.Banners.FirstOrDefault(b => b.Id == bannerId);
return banner != null && banner.LinkType == linkType && !string.IsNullOrEmpty(banner.LinkUrl);
}
///
/// Property 7: LinkType 3 时 LinkUrl 和 AppId 都必填
/// *For any* Banner with LinkType 3, both LinkUrl and AppId SHALL be non-empty.
///
/// **Validates: Requirements 2.5**
///
[Property(MaxTest = 100)]
public bool LinkTypeValidation_LinkUrlAndAppIdRequiredForType3(PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = new ContentService(dbContext, _mockLogger.Object);
// 测试场景:LinkUrl 为空
var request1 = new CreateBannerRequest
{
Title = $"Test Banner {seed.Get}",
ImageUrl = $"https://example.com/image_{seed.Get}.jpg",
LinkType = 3, // 小程序
LinkUrl = null, // 空的 LinkUrl
AppId = "wx1234567890",
Sort = seed.Get % 100,
Status = 1
};
bool linkUrlValidationFailed = false;
try
{
service.CreateBannerAsync(request1).GetAwaiter().GetResult();
}
catch (BusinessException ex)
{
linkUrlValidationFailed = ex.Code == ErrorCodes.BannerLinkUrlRequired;
}
// 测试场景:AppId 为空
var request2 = new CreateBannerRequest
{
Title = $"Test Banner {seed.Get + 1}",
ImageUrl = $"https://example.com/image_{seed.Get + 1}.jpg",
LinkType = 3, // 小程序
LinkUrl = "/pages/index/index",
AppId = null, // 空的 AppId
Sort = seed.Get % 100,
Status = 1
};
bool appIdValidationFailed = false;
try
{
service.CreateBannerAsync(request2).GetAwaiter().GetResult();
}
catch (BusinessException ex)
{
appIdValidationFailed = ex.Code == ErrorCodes.BannerLinkUrlRequired;
}
return linkUrlValidationFailed && appIdValidationFailed;
}
///
/// Property 7: LinkType 3 时提供有效 LinkUrl 和 AppId 应该成功
///
/// **Validates: Requirements 2.5**
///
[Property(MaxTest = 100)]
public bool LinkTypeValidation_ValidLinkUrlAndAppIdForType3_Success(PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = new ContentService(dbContext, _mockLogger.Object);
var request = new CreateBannerRequest
{
Title = $"Test Banner {seed.Get}",
ImageUrl = $"https://example.com/image_{seed.Get}.jpg",
LinkType = 3, // 小程序
LinkUrl = "/pages/index/index",
AppId = $"wx{seed.Get:D10}", // 有效的 AppId
Sort = seed.Get % 100,
Status = 1
};
// Act
var bannerId = service.CreateBannerAsync(request).GetAwaiter().GetResult();
// Assert: 应该创建成功
var banner = dbContext.Banners.FirstOrDefault(b => b.Id == bannerId);
return banner != null
&& banner.LinkType == 3
&& !string.IsNullOrEmpty(banner.LinkUrl)
&& !string.IsNullOrEmpty(banner.AppId);
}
///
/// Property 7: LinkType 0 时不需要 LinkUrl 和 AppId
/// *For any* Banner with LinkType 0, the LinkUrl and AppId are optional.
///
/// **Validates: Requirements 2.3**
///
[Property(MaxTest = 100)]
public bool LinkTypeValidation_NoValidationForType0(PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var service = new ContentService(dbContext, _mockLogger.Object);
var request = new CreateBannerRequest
{
Title = $"Test Banner {seed.Get}",
ImageUrl = $"https://example.com/image_{seed.Get}.jpg",
LinkType = 0, // 无跳转
LinkUrl = null, // 空的 LinkUrl
AppId = null, // 空的 AppId
Sort = seed.Get % 100,
Status = 1
};
// Act
try
{
var bannerId = service.CreateBannerAsync(request).GetAwaiter().GetResult();
// Assert: 应该创建成功
var banner = dbContext.Banners.FirstOrDefault(b => b.Id == bannerId);
return banner != null && banner.LinkType == 0;
}
catch (BusinessException)
{
return false; // 不应该抛出异常
}
}
///
/// Property 7: 更新时也应验证 LinkType
///
/// **Validates: Requirements 2.3, 2.4, 2.5**
///
[Property(MaxTest = 50)]
public bool LinkTypeValidation_UpdateAlsoValidates(PositiveInt seed)
{
// Arrange: 先创建一个有效的轮播图
using var dbContext = CreateDbContext();
var bannerId = CreateTestBanner(dbContext, seed.Get);
var service = new ContentService(dbContext, _mockLogger.Object);
// Act: 尝试更新为 LinkType 1 但不提供 LinkUrl
var updateRequest = new UpdateBannerRequest
{
Id = bannerId,
Title = $"Updated Banner {seed.Get}",
ImageUrl = $"https://example.com/updated_{seed.Get}.jpg",
LinkType = 1, // 内部页面
LinkUrl = null, // 空的 LinkUrl
Sort = seed.Get % 100,
Status = 1
};
// Assert: 应该抛出 BusinessException
try
{
service.UpdateBannerAsync(updateRequest).GetAwaiter().GetResult();
return false;
}
catch (BusinessException ex)
{
return ex.Code == ErrorCodes.BannerLinkUrlRequired;
}
}
#endregion
#region 辅助方法
///
/// 创建内存数据库上下文
///
private AdminBusinessDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new AdminBusinessDbContext(options);
}
///
/// 创建测试轮播图
///
private long CreateTestBanner(AdminBusinessDbContext dbContext, int seed)
{
var banner = new Banner
{
Title = $"Test Banner {seed}",
ImageUrl = $"https://example.com/image_{seed}.jpg",
LinkType = 0,
Sort = seed % 100,
Status = 1,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now,
IsDeleted = false
};
dbContext.Banners.Add(banner);
dbContext.SaveChanges();
return banner.Id;
}
///
/// 创建指定 Sort 值的测试轮播图
///
private long CreateTestBannerWithSort(AdminBusinessDbContext dbContext, int seed, int sort)
{
var banner = new Banner
{
Title = $"Test Banner {seed}",
ImageUrl = $"https://example.com/image_{seed}.jpg",
LinkType = 0,
Sort = sort,
Status = 1,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now,
IsDeleted = false
};
dbContext.Banners.Add(banner);
dbContext.SaveChanges();
return banner.Id;
}
///
/// 生成不同的 Sort 值列表
///
private List GenerateSortValues(int seed, int count)
{
var values = new List();
var random = new Random(seed);
for (int i = 0; i < count; i++)
{
values.Add(random.Next(1, 1000));
}
return values;
}
#endregion
}