diff --git a/.kiro/specs/homepage-prize-announcement/tasks.md b/.kiro/specs/homepage-prize-announcement/tasks.md index ee14f239..c6c48c09 100644 --- a/.kiro/specs/homepage-prize-announcement/tasks.md +++ b/.kiro/specs/homepage-prize-announcement/tasks.md @@ -6,97 +6,97 @@ ## Tasks -- [ ] 1. 创建数据库实体和表结构 - - [ ] 1.1 创建 PrizeAnnouncement 实体类 +- [x] 1. 创建数据库实体和表结构 + - [x] 1.1 创建 PrizeAnnouncement 实体类 - 在 `HoneyBox.Model/Entities/` 创建 `PrizeAnnouncement.cs` - 包含 Id, UserAvatar, UserName, PrizeLevel, PrizeName, Sort, IsEnabled, CreatedAt, UpdatedAt 字段 - _Requirements: 1.1, 1.6, 1.7_ - - [ ] 1.2 更新 HoneyBoxDbContext 添加 DbSet + - [x] 1.2 更新 HoneyBoxDbContext 添加 DbSet - 在 `HoneyBoxDbContext.cs` 添加 `DbSet` 和实体配置 - 配置表名、索引、字段约束 - _Requirements: 1.1_ - - [ ] 1.3 创建数据库建表 SQL 脚本 + - [x] 1.3 创建数据库建表 SQL 脚本 - 在 `server/HoneyBox/scripts/` 创建 `create_prize_announcements.sql` - _Requirements: 1.1_ -- [ ] 2. 实现后台管理 API - - [ ] 2.1 创建 DTO 模型 +- [x] 2. 实现后台管理 API + - [x] 2.1 创建 DTO 模型 - 在 `HoneyBox.Admin.Business/Models/` 创建 `Announcement/AnnouncementModels.cs` - 包含 CreateAnnouncementRequest, UpdateAnnouncementRequest, AnnouncementAdminDto - _Requirements: 1.1, 1.2, 1.5_ - - [ ] 2.2 创建 Service 接口和实现 + - [x] 2.2 创建 Service 接口和实现 - 在 `HoneyBox.Admin.Business/Services/Interfaces/` 创建 `IAnnouncementService.cs` - 在 `HoneyBox.Admin.Business/Services/` 创建 `AnnouncementService.cs` - 实现 GetListAsync, CreateAsync, UpdateAsync, DeleteAsync, ToggleStatusAsync - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ - - [ ] 2.3 编写 Service 属性测试 + - [x] 2.3 编写 Service 属性测试 - **Property 1: CRUD Round-Trip Consistency** - **Property 4: Required Field Validation** - **Validates: Requirements 1.1, 1.2, 1.3, 1.5, 2.4** - - [ ] 2.4 创建 Admin Controller + - [x] 2.4 创建 Admin Controller - 在 `HoneyBox.Admin.Business/Controllers/` 创建 `AnnouncementController.cs` - 实现 GET/POST/PUT/DELETE/PATCH 端点 - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.6_ -- [ ] 3. Checkpoint - 后台管理 API 验证 +- [x] 3. Checkpoint - 后台管理 API 验证 - Ensure all tests pass, ask the user if questions arise. -- [ ] 4. 实现用户端 API - - [ ] 4.1 创建用户端 DTO +- [x] 4. 实现用户端 API + - [x] 4.1 创建用户端 DTO - 在 `HoneyBox.Model/Models/Announcement/` 创建 `PrizeAnnouncementDto.cs` - _Requirements: 2.4_ - - [ ] 4.2 创建用户端 Service 接口和实现 + - [x] 4.2 创建用户端 Service 接口和实现 - 在 `HoneyBox.Core/Interfaces/` 创建 `IPrizeAnnouncementService.cs` - 在 `HoneyBox.Core/Services/` 创建 `PrizeAnnouncementService.cs` - 实现 GetEnabledAnnouncementsAsync 方法 - _Requirements: 2.1, 2.2, 2.3, 2.4_ - - [ ] 4.3 编写用户端 Service 属性测试 + - [x] 4.3 编写用户端 Service 属性测试 - **Property 2: Enabled Filter Correctness** - **Property 3: Sort Ordering Preservation** - **Validates: Requirements 2.2, 2.3, 1.6, 1.7** - - [ ] 4.4 在 ConfigController 添加 API 端点 + - [x] 4.4 在 ConfigController 添加 API 端点 - 添加 `GET /api/getPrizeAnnouncements` 端点 - _Requirements: 2.1_ - - [ ] 4.5 注册依赖注入 + - [x] 4.5 注册依赖注入 - 在 Autofac 模块中注册 IPrizeAnnouncementService - _Requirements: 2.1_ -- [ ] 5. Checkpoint - 用户端 API 验证 +- [x] 5. Checkpoint - 用户端 API 验证 - Ensure all tests pass, ask the user if questions arise. -- [ ] 6. 实现前端公告组件 - - [ ] 6.1 创建 API 接口文件 +- [x] 6. 实现前端公告组件 + - [x] 6.1 创建 API 接口文件 - 在 `honey_box/common/server/` 创建 `announcement.js` - 导出 getPrizeAnnouncements 函数 - _Requirements: 2.1_ - - [ ] 6.2 创建公告轮播组件 + - [x] 6.2 创建公告轮播组件 - 在 `honey_box/components/` 创建 `prize-announcement/prize-announcement.vue` - 实现4秒自动轮播、进出动画、循环展示 - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_ - - [ ] 6.3 创建中奖详情弹窗组件 + - [x] 6.3 创建中奖详情弹窗组件 - 在 `honey_box/components/` 创建 `prize-detail-popup/prize-detail-popup.vue` - 实现弹窗展示、奖品名称显示、【我也要玩】按钮 - _Requirements: 4.1, 4.2, 4.3, 4.4_ - - [ ] 6.4 集成到首页 + - [x] 6.4 集成到首页 - 修改 `honey_box/pages/shouye/index.vue` - 替换静态公告为动态轮播组件 - 添加点击弹窗交互 - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1_ -- [ ] 7. 实现后台管理前端页面 - - [ ] 7.1 创建 API 接口文件 +- [x] 7. 实现后台管理前端页面 + - [x] 7.1 创建 API 接口文件 - 在 `admin-web/src/api/business/` 创建 `announcement.ts` - 导出 CRUD API 函数 - _Requirements: 5.1_ - - [ ] 7.2 创建公告管理页面 + - [x] 7.2 创建公告管理页面 - 在 `admin-web/src/views/business/` 创建 `announcement/index.vue` - 实现列表展示、新增/编辑弹窗、删除确认、状态切换 - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_ - - [ ] 7.3 添加路由和菜单配置 + - [x] 7.3 添加路由和菜单配置 - 在路由配置中添加公告管理页面路由 - _Requirements: 5.1_ -- [ ] 8. Final Checkpoint - 全功能验证 +- [x] 8. Final Checkpoint - 全功能验证 - Ensure all tests pass, ask the user if questions arise. ## Notes diff --git a/honey_box/common/server/announcement.js b/honey_box/common/server/announcement.js new file mode 100644 index 00000000..7e3b4438 --- /dev/null +++ b/honey_box/common/server/announcement.js @@ -0,0 +1,12 @@ +import RequestManager from '../request'; + + +/** + * 获取中奖公告列表 + * 返回启用状态的公告列表,按排序字段升序 + * @returns {Promise} 公告列表 + */ +export const getPrizeAnnouncements = async () => { + const res = await RequestManager.get("getPrizeAnnouncements", {}); + return res.data; +} diff --git a/honey_box/components/prize-announcement/prize-announcement.vue b/honey_box/components/prize-announcement/prize-announcement.vue new file mode 100644 index 00000000..3e148134 --- /dev/null +++ b/honey_box/components/prize-announcement/prize-announcement.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/honey_box/components/prize-detail-popup/prize-detail-popup.vue b/honey_box/components/prize-detail-popup/prize-detail-popup.vue new file mode 100644 index 00000000..bbe5d5c4 --- /dev/null +++ b/honey_box/components/prize-detail-popup/prize-detail-popup.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/honey_box/pages/shouye/index.vue b/honey_box/pages/shouye/index.vue index c3f7e71d..b1cea7d0 100644 --- a/honey_box/pages/shouye/index.vue +++ b/honey_box/pages/shouye/index.vue @@ -17,26 +17,12 @@ 哈尼盲盒 - - - - - - - - - - - - 用户123456抽中了一等级的限定盲盒! - - - + + @@ -146,12 +132,21 @@ + + + + + diff --git a/server/HoneyBox/src/HoneyBox.Api/Controllers/ConfigController.cs b/server/HoneyBox/src/HoneyBox.Api/Controllers/ConfigController.cs index 282a9318..28f9d363 100644 --- a/server/HoneyBox/src/HoneyBox.Api/Controllers/ConfigController.cs +++ b/server/HoneyBox/src/HoneyBox.Api/Controllers/ConfigController.cs @@ -1,5 +1,6 @@ using HoneyBox.Core.Interfaces; using HoneyBox.Model.Base; +using HoneyBox.Model.Models.Announcement; using HoneyBox.Model.Models.Config; using HoneyBox.Model.Models.FloatBall; using Microsoft.AspNetCore.Authorization; @@ -19,15 +20,18 @@ public class ConfigController : ControllerBase { private readonly IConfigService _configService; private readonly IFloatBallService _floatBallService; + private readonly IPrizeAnnouncementService _prizeAnnouncementService; private readonly ILogger _logger; public ConfigController( IConfigService configService, IFloatBallService floatBallService, + IPrizeAnnouncementService prizeAnnouncementService, ILogger logger) { _configService = configService; _floatBallService = floatBallService; + _prizeAnnouncementService = prizeAnnouncementService; _logger = logger; } @@ -217,4 +221,32 @@ public class ConfigController : ControllerBase return ApiResponse>.Fail("获取悬浮球配置失败"); } } + + /// + /// 获取中奖公告列表 + /// + /// + /// GET /api/getPrizeAnnouncements + /// + /// 返回启用状态的公告列表,按排序字段升序排列 + /// 支持未登录访问 + /// Requirements: 2.1 + /// + /// 中奖公告列表 + [HttpGet("getPrizeAnnouncements")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetPrizeAnnouncements() + { + try + { + var result = await _prizeAnnouncementService.GetEnabledAnnouncementsAsync(); + return ApiResponse>.Success(result, "获取中奖公告成功"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get prize announcements"); + return ApiResponse>.Fail("获取中奖公告失败"); + } + } } diff --git a/server/HoneyBox/src/HoneyBox.Core/Interfaces/IPrizeAnnouncementService.cs b/server/HoneyBox/src/HoneyBox.Core/Interfaces/IPrizeAnnouncementService.cs new file mode 100644 index 00000000..0b8f0377 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Core/Interfaces/IPrizeAnnouncementService.cs @@ -0,0 +1,18 @@ +using HoneyBox.Model.Models.Announcement; + +namespace HoneyBox.Core.Interfaces; + +/// +/// 中奖公告服务接口(用户端) +/// +public interface IPrizeAnnouncementService +{ + /// + /// 获取启用状态的公告列表 + /// + /// + /// 只返回启用状态的公告,按排序字段升序排列 + /// + /// 公告列表 + Task> GetEnabledAnnouncementsAsync(); +} diff --git a/server/HoneyBox/src/HoneyBox.Core/Services/PrizeAnnouncementService.cs b/server/HoneyBox/src/HoneyBox.Core/Services/PrizeAnnouncementService.cs new file mode 100644 index 00000000..4cbbb806 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Core/Services/PrizeAnnouncementService.cs @@ -0,0 +1,52 @@ +using HoneyBox.Core.Interfaces; +using HoneyBox.Model.Data; +using HoneyBox.Model.Models.Announcement; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace HoneyBox.Core.Services; + +/// +/// 中奖公告服务实现(用户端) +/// +public class PrizeAnnouncementService : IPrizeAnnouncementService +{ + private readonly HoneyBoxDbContext _dbContext; + private readonly ILogger _logger; + + public PrizeAnnouncementService( + HoneyBoxDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + public async Task> GetEnabledAnnouncementsAsync() + { + try + { + // 只返回启用状态的公告,按排序字段升序排列 + var announcements = await _dbContext.PrizeAnnouncements + .Where(a => a.IsEnabled) + .OrderBy(a => a.Sort) + .Select(a => new PrizeAnnouncementDto + { + Id = a.Id, + UserAvatar = a.UserAvatar ?? string.Empty, + UserName = a.UserName, + PrizeLevel = a.PrizeLevel, + PrizeName = a.PrizeName + }) + .ToListAsync(); + + return announcements; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get enabled prize announcements"); + return new List(); + } + } +} diff --git a/server/HoneyBox/src/HoneyBox.Infrastructure/Modules/ServiceModule.cs b/server/HoneyBox/src/HoneyBox.Infrastructure/Modules/ServiceModule.cs index bd305a3f..a30f0212 100644 --- a/server/HoneyBox/src/HoneyBox.Infrastructure/Modules/ServiceModule.cs +++ b/server/HoneyBox/src/HoneyBox.Infrastructure/Modules/ServiceModule.cs @@ -351,5 +351,15 @@ public class ServiceModule : Module var logger = c.Resolve>(); return new MallService(dbContext, wechatPayService, logger); }).As().InstancePerLifetimeScope(); + + // ========== 公告系统服务注册 ========== + + // 注册中奖公告服务(用户端) + builder.Register(c => + { + var dbContext = c.Resolve(); + var logger = c.Resolve>(); + return new PrizeAnnouncementService(dbContext, logger); + }).As().InstancePerLifetimeScope(); } } diff --git a/server/HoneyBox/src/HoneyBox.Model/Data/HoneyBoxDbContext.cs b/server/HoneyBox/src/HoneyBox.Model/Data/HoneyBoxDbContext.cs index 596cb23c..c8b786bf 100644 --- a/server/HoneyBox/src/HoneyBox.Model/Data/HoneyBoxDbContext.cs +++ b/server/HoneyBox/src/HoneyBox.Model/Data/HoneyBoxDbContext.cs @@ -128,6 +128,8 @@ public partial class HoneyBoxDbContext : DbContext public virtual DbSet GoodsDesignatedPrizes { get; set; } + public virtual DbSet PrizeAnnouncements { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // Connection string is configured in Program.cs via dependency injection @@ -3531,6 +3533,55 @@ public partial class HoneyBoxDbContext : DbContext .HasConstraintName("fk_goods_designated_prizes_users"); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("pk_prize_announcements"); + + entity.ToTable("prize_announcements", tb => tb.HasComment("中奖公告配置表,存储首页假中奖公告信息")); + + entity.HasIndex(e => e.IsEnabled, "ix_prize_announcements_is_enabled"); + entity.HasIndex(e => e.Sort, "ix_prize_announcements_sort"); + + entity.Property(e => e.Id) + .HasComment("主键ID") + .HasColumnName("id"); + entity.Property(e => e.UserAvatar) + .HasMaxLength(500) + .HasComment("用户头像URL") + .HasColumnName("user_avatar"); + entity.Property(e => e.UserName) + .HasMaxLength(50) + .IsRequired() + .HasComment("用户名称") + .HasColumnName("user_name"); + entity.Property(e => e.PrizeLevel) + .HasMaxLength(20) + .IsRequired() + .HasComment("奖品等级(如:无上、传说、史诗、稀有)") + .HasColumnName("prize_level"); + entity.Property(e => e.PrizeName) + .HasMaxLength(100) + .IsRequired() + .HasComment("奖品名称") + .HasColumnName("prize_name"); + entity.Property(e => e.Sort) + .HasDefaultValue(0) + .HasComment("排序值,越小越靠前") + .HasColumnName("sort"); + entity.Property(e => e.IsEnabled) + .HasDefaultValue(true) + .HasComment("是否启用") + .HasColumnName("is_enabled"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("(getdate())") + .HasComment("创建时间") + .HasColumnName("created_at"); + entity.Property(e => e.UpdatedAt) + .HasDefaultValueSql("(getdate())") + .HasComment("更新时间") + .HasColumnName("updated_at"); + }); + OnModelCreatingPartial(modelBuilder); } diff --git a/server/HoneyBox/src/HoneyBox.Model/Entities/PrizeAnnouncement.cs b/server/HoneyBox/src/HoneyBox.Model/Entities/PrizeAnnouncement.cs new file mode 100644 index 00000000..a0e9170a --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Model/Entities/PrizeAnnouncement.cs @@ -0,0 +1,54 @@ +using System; + +namespace HoneyBox.Model.Entities; + +/// +/// 中奖公告配置表,存储首页假中奖公告信息 +/// +public partial class PrizeAnnouncement +{ + /// + /// 主键ID + /// + public int Id { get; set; } + + /// + /// 用户头像URL + /// + public string? UserAvatar { get; set; } + + /// + /// 用户名称 + /// + public string UserName { get; set; } = null!; + + /// + /// 奖品等级(如:无上、传说、史诗、稀有) + /// + public string PrizeLevel { get; set; } = null!; + + /// + /// 奖品名称 + /// + public string PrizeName { get; set; } = null!; + + /// + /// 排序值,越小越靠前 + /// + public int Sort { get; set; } + + /// + /// 是否启用 + /// + public bool IsEnabled { get; set; } + + /// + /// 创建时间 + /// + public DateTime CreatedAt { get; set; } + + /// + /// 更新时间 + /// + public DateTime UpdatedAt { get; set; } +} diff --git a/server/HoneyBox/src/HoneyBox.Model/Models/Announcement/PrizeAnnouncementDto.cs b/server/HoneyBox/src/HoneyBox.Model/Models/Announcement/PrizeAnnouncementDto.cs new file mode 100644 index 00000000..6f72c152 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Model/Models/Announcement/PrizeAnnouncementDto.cs @@ -0,0 +1,39 @@ +namespace HoneyBox.Model.Models.Announcement; + +using System.Text.Json.Serialization; + +/// +/// 中奖公告响应DTO(用户端) +/// +public class PrizeAnnouncementDto +{ + /// + /// 公告ID + /// + [JsonPropertyName("id")] + public int Id { get; set; } + + /// + /// 用户头像URL + /// + [JsonPropertyName("user_avatar")] + public string UserAvatar { get; set; } = string.Empty; + + /// + /// 用户名称 + /// + [JsonPropertyName("user_name")] + public string UserName { get; set; } = string.Empty; + + /// + /// 奖品等级(如:无上、传说、史诗、稀有) + /// + [JsonPropertyName("prize_level")] + public string PrizeLevel { get; set; } = string.Empty; + + /// + /// 奖品名称 + /// + [JsonPropertyName("prize_name")] + public string PrizeName { get; set; } = string.Empty; +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/AnnouncementServicePropertyTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/AnnouncementServicePropertyTests.cs new file mode 100644 index 00000000..0cf4ae2a --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/AnnouncementServicePropertyTests.cs @@ -0,0 +1,830 @@ +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Admin.Business.Models; +using HoneyBox.Admin.Business.Models.Announcement; +using HoneyBox.Admin.Business.Services; +using HoneyBox.Model.Data; +using HoneyBox.Model.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// AnnouncementService 属性测试 +/// 测试首页中大奖公告功能的核心属性 +/// +public class AnnouncementServicePropertyTests +{ + private readonly Mock> _mockLogger = new(); + + private (HoneyBoxDbContext dbContext, AnnouncementService service) CreateService() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + var dbContext = new HoneyBoxDbContext(options); + var service = new AnnouncementService(dbContext, _mockLogger.Object); + return (dbContext, service); + } + + /// + /// 生成有效的非空字符串(用于必填字段) + /// + private static string GenerateValidString(string prefix, int seed) + { + return $"{prefix}_{Math.Abs(seed) % 1000}"; + } + + #region Property 1: CRUD Round-Trip Consistency + + /// + /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** + /// For any valid announcement data, creating an announcement and then reading it back + /// should return the same data. + /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** + /// + [Property(MaxTest = 100)] + public bool CrudRoundTrip_CreateAndRead_ShouldReturnSameData( + NonEmptyString userName, + NonEmptyString prizeLevel, + NonEmptyString prizeName, + PositiveInt sort, + bool isEnabled) + { + var actualUserName = userName.Get.Trim(); + var actualPrizeLevel = prizeLevel.Get.Trim(); + var actualPrizeName = prizeName.Get.Trim(); + var actualSort = sort.Get % 1000; + + // Skip if any required field becomes empty after trim + if (string.IsNullOrWhiteSpace(actualUserName) || + string.IsNullOrWhiteSpace(actualPrizeLevel) || + string.IsNullOrWhiteSpace(actualPrizeName)) + { + return true; // Skip this test case + } + + var (dbContext, service) = CreateService(); + + try + { + // Create announcement + var createRequest = new CreateAnnouncementRequest + { + UserAvatar = "http://test.com/avatar.jpg", + UserName = actualUserName, + PrizeLevel = actualPrizeLevel, + PrizeName = actualPrizeName, + Sort = actualSort, + IsEnabled = isEnabled + }; + + var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); + + // Read it back + var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); + + // Verify data consistency + return retrieved.UserName == actualUserName && + retrieved.PrizeLevel == actualPrizeLevel && + retrieved.PrizeName == actualPrizeName && + retrieved.Sort == actualSort && + retrieved.IsEnabled == isEnabled && + retrieved.UserAvatar == "http://test.com/avatar.jpg"; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** + /// For any valid announcement data, updating it and reading again should reflect the updates. + /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** + /// + [Property(MaxTest = 100)] + public bool CrudRoundTrip_UpdateAndRead_ShouldReflectUpdates( + PositiveInt seed1, + PositiveInt seed2, + PositiveInt sort1, + PositiveInt sort2, + bool isEnabled1, + bool isEnabled2) + { + var userName1 = GenerateValidString("User", seed1.Get); + var prizeLevel1 = GenerateValidString("Level", seed1.Get); + var prizeName1 = GenerateValidString("Prize", seed1.Get); + var userName2 = GenerateValidString("UpdatedUser", seed2.Get); + var prizeLevel2 = GenerateValidString("UpdatedLevel", seed2.Get); + var prizeName2 = GenerateValidString("UpdatedPrize", seed2.Get); + var actualSort1 = sort1.Get % 1000; + var actualSort2 = sort2.Get % 1000; + + var (dbContext, service) = CreateService(); + + try + { + // Create initial announcement + var createRequest = new CreateAnnouncementRequest + { + UserAvatar = "http://test.com/avatar1.jpg", + UserName = userName1, + PrizeLevel = prizeLevel1, + PrizeName = prizeName1, + Sort = actualSort1, + IsEnabled = isEnabled1 + }; + + var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); + + // Update the announcement + var updateRequest = new UpdateAnnouncementRequest + { + UserAvatar = "http://test.com/avatar2.jpg", + UserName = userName2, + PrizeLevel = prizeLevel2, + PrizeName = prizeName2, + Sort = actualSort2, + IsEnabled = isEnabled2 + }; + + service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); + + // Read it back + var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); + + // Verify updates are reflected + return retrieved.UserName == userName2 && + retrieved.PrizeLevel == prizeLevel2 && + retrieved.PrizeName == prizeName2 && + retrieved.Sort == actualSort2 && + retrieved.IsEnabled == isEnabled2 && + retrieved.UserAvatar == "http://test.com/avatar2.jpg"; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** + /// For any valid announcement, deleting it should make it no longer retrievable. + /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** + /// + [Property(MaxTest = 100)] + public bool CrudRoundTrip_Delete_ShouldMakeUnretrievable(PositiveInt seed) + { + var userName = GenerateValidString("User", seed.Get); + var prizeLevel = GenerateValidString("Level", seed.Get); + var prizeName = GenerateValidString("Prize", seed.Get); + + var (dbContext, service) = CreateService(); + + try + { + // Create announcement + var createRequest = new CreateAnnouncementRequest + { + UserName = userName, + PrizeLevel = prizeLevel, + PrizeName = prizeName, + Sort = 0, + IsEnabled = true + }; + + var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); + var createdId = created.Id; + + // Delete the announcement + var deleteResult = service.DeleteAsync(createdId).GetAwaiter().GetResult(); + + if (!deleteResult) + { + return false; + } + + // Try to retrieve it - should throw NotFound exception + try + { + service.GetByIdAsync(createdId).GetAwaiter().GetResult(); + return false; // Should not reach here + } + catch (BusinessException ex) + { + return ex.Code == BusinessErrorCodes.NotFound; + } + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** + /// Partial updates should only modify specified fields, leaving others unchanged. + /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** + /// + [Property(MaxTest = 100)] + public bool CrudRoundTrip_PartialUpdate_ShouldOnlyModifySpecifiedFields( + PositiveInt seed, + PositiveInt newSort) + { + var userName = GenerateValidString("User", seed.Get); + var prizeLevel = GenerateValidString("Level", seed.Get); + var prizeName = GenerateValidString("Prize", seed.Get); + var originalSort = seed.Get % 500; + var updatedSort = (newSort.Get % 500) + 500; // Ensure different value + + var (dbContext, service) = CreateService(); + + try + { + // Create announcement + var createRequest = new CreateAnnouncementRequest + { + UserAvatar = "http://test.com/original.jpg", + UserName = userName, + PrizeLevel = prizeLevel, + PrizeName = prizeName, + Sort = originalSort, + IsEnabled = true + }; + + var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); + + // Partial update - only update Sort + var updateRequest = new UpdateAnnouncementRequest + { + Sort = updatedSort + }; + + service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); + + // Read it back + var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); + + // Verify only Sort changed, other fields remain the same + return retrieved.Sort == updatedSort && + retrieved.UserName == userName && + retrieved.PrizeLevel == prizeLevel && + retrieved.PrizeName == prizeName && + retrieved.UserAvatar == "http://test.com/original.jpg" && + retrieved.IsEnabled == true; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency** + /// Multiple CRUD operations should maintain data consistency. + /// **Validates: Requirements 1.1, 1.2, 1.3, 2.4** + /// + [Property(MaxTest = 50)] + public bool CrudRoundTrip_MultipleCrudOperations_ShouldMaintainConsistency(PositiveInt operationCount) + { + var actualCount = Math.Max(2, operationCount.Get % 5 + 2); + + var (dbContext, service) = CreateService(); + + try + { + var createdIds = new List(); + + // Create multiple announcements + for (int i = 0; i < actualCount; i++) + { + var createRequest = new CreateAnnouncementRequest + { + UserName = $"User_{i}", + PrizeLevel = $"Level_{i}", + PrizeName = $"Prize_{i}", + Sort = i, + IsEnabled = true + }; + + var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); + createdIds.Add(created.Id); + } + + // Update each announcement + for (int i = 0; i < actualCount; i++) + { + var updateRequest = new UpdateAnnouncementRequest + { + UserName = $"UpdatedUser_{i}", + Sort = i + 100 + }; + + service.UpdateAsync(createdIds[i], updateRequest).GetAwaiter().GetResult(); + } + + // Verify all updates + for (int i = 0; i < actualCount; i++) + { + var retrieved = service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult(); + if (retrieved.UserName != $"UpdatedUser_{i}" || retrieved.Sort != i + 100) + { + return false; + } + } + + // Delete half of them + for (int i = 0; i < actualCount / 2; i++) + { + service.DeleteAsync(createdIds[i]).GetAwaiter().GetResult(); + } + + // Verify deleted ones are not retrievable + for (int i = 0; i < actualCount / 2; i++) + { + try + { + service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult(); + return false; // Should have thrown + } + catch (BusinessException ex) + { + if (ex.Code != BusinessErrorCodes.NotFound) + { + return false; + } + } + } + + // Verify remaining ones are still retrievable + for (int i = actualCount / 2; i < actualCount; i++) + { + var retrieved = service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult(); + if (retrieved.UserName != $"UpdatedUser_{i}") + { + return false; + } + } + + return true; + } + finally + { + dbContext.Dispose(); + } + } + + #endregion + + #region Property 4: Required Field Validation + + /// + /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** + /// For any create request where UserName is empty or whitespace-only, + /// the system should reject the request and return a validation error. + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool RequiredFieldValidation_EmptyUserName_ShouldRejectCreate(PositiveInt seed) + { + var prizeLevel = GenerateValidString("Level", seed.Get); + var prizeName = GenerateValidString("Prize", seed.Get); + + var (dbContext, service) = CreateService(); + + try + { + var emptyUserNames = new[] { "", " ", "\t", "\n", " \t\n " }; + + foreach (var emptyUserName in emptyUserNames) + { + var createRequest = new CreateAnnouncementRequest + { + UserName = emptyUserName, + PrizeLevel = prizeLevel, + PrizeName = prizeName, + Sort = 0, + IsEnabled = true + }; + + try + { + service.CreateAsync(createRequest).GetAwaiter().GetResult(); + return false; // Should have thrown + } + catch (BusinessException ex) + { + if (ex.Code != BusinessErrorCodes.ValidationFailed) + { + return false; + } + } + } + + // Verify database is not modified + var count = dbContext.PrizeAnnouncements.Count(); + return count == 0; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** + /// For any create request where PrizeLevel is empty or whitespace-only, + /// the system should reject the request and return a validation error. + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool RequiredFieldValidation_EmptyPrizeLevel_ShouldRejectCreate(PositiveInt seed) + { + var userName = GenerateValidString("User", seed.Get); + var prizeName = GenerateValidString("Prize", seed.Get); + + var (dbContext, service) = CreateService(); + + try + { + var emptyPrizeLevels = new[] { "", " ", "\t", "\n", " \t\n " }; + + foreach (var emptyPrizeLevel in emptyPrizeLevels) + { + var createRequest = new CreateAnnouncementRequest + { + UserName = userName, + PrizeLevel = emptyPrizeLevel, + PrizeName = prizeName, + Sort = 0, + IsEnabled = true + }; + + try + { + service.CreateAsync(createRequest).GetAwaiter().GetResult(); + return false; // Should have thrown + } + catch (BusinessException ex) + { + if (ex.Code != BusinessErrorCodes.ValidationFailed) + { + return false; + } + } + } + + // Verify database is not modified + var count = dbContext.PrizeAnnouncements.Count(); + return count == 0; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** + /// For any create request where PrizeName is empty or whitespace-only, + /// the system should reject the request and return a validation error. + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool RequiredFieldValidation_EmptyPrizeName_ShouldRejectCreate(PositiveInt seed) + { + var userName = GenerateValidString("User", seed.Get); + var prizeLevel = GenerateValidString("Level", seed.Get); + + var (dbContext, service) = CreateService(); + + try + { + var emptyPrizeNames = new[] { "", " ", "\t", "\n", " \t\n " }; + + foreach (var emptyPrizeName in emptyPrizeNames) + { + var createRequest = new CreateAnnouncementRequest + { + UserName = userName, + PrizeLevel = prizeLevel, + PrizeName = emptyPrizeName, + Sort = 0, + IsEnabled = true + }; + + try + { + service.CreateAsync(createRequest).GetAwaiter().GetResult(); + return false; // Should have thrown + } + catch (BusinessException ex) + { + if (ex.Code != BusinessErrorCodes.ValidationFailed) + { + return false; + } + } + } + + // Verify database is not modified + var count = dbContext.PrizeAnnouncements.Count(); + return count == 0; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** + /// For any update request where UserName is set to empty or whitespace-only, + /// the system should reject the request and return a validation error without modifying the database. + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool RequiredFieldValidation_EmptyUserName_ShouldRejectUpdate(PositiveInt seed) + { + var userName = GenerateValidString("User", seed.Get); + var prizeLevel = GenerateValidString("Level", seed.Get); + var prizeName = GenerateValidString("Prize", seed.Get); + + var (dbContext, service) = CreateService(); + + try + { + // First create a valid announcement + var createRequest = new CreateAnnouncementRequest + { + UserName = userName, + PrizeLevel = prizeLevel, + PrizeName = prizeName, + Sort = 0, + IsEnabled = true + }; + + var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); + + var emptyUserNames = new[] { "", " ", "\t", "\n" }; + + foreach (var emptyUserName in emptyUserNames) + { + var updateRequest = new UpdateAnnouncementRequest + { + UserName = emptyUserName + }; + + try + { + service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); + return false; // Should have thrown + } + catch (BusinessException ex) + { + if (ex.Code != BusinessErrorCodes.ValidationFailed) + { + return false; + } + } + } + + // Verify original data is not modified + var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); + return retrieved.UserName == userName; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** + /// For any update request where PrizeLevel is set to empty or whitespace-only, + /// the system should reject the request and return a validation error without modifying the database. + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool RequiredFieldValidation_EmptyPrizeLevel_ShouldRejectUpdate(PositiveInt seed) + { + var userName = GenerateValidString("User", seed.Get); + var prizeLevel = GenerateValidString("Level", seed.Get); + var prizeName = GenerateValidString("Prize", seed.Get); + + var (dbContext, service) = CreateService(); + + try + { + // First create a valid announcement + var createRequest = new CreateAnnouncementRequest + { + UserName = userName, + PrizeLevel = prizeLevel, + PrizeName = prizeName, + Sort = 0, + IsEnabled = true + }; + + var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); + + var emptyPrizeLevels = new[] { "", " ", "\t", "\n" }; + + foreach (var emptyPrizeLevel in emptyPrizeLevels) + { + var updateRequest = new UpdateAnnouncementRequest + { + PrizeLevel = emptyPrizeLevel + }; + + try + { + service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); + return false; // Should have thrown + } + catch (BusinessException ex) + { + if (ex.Code != BusinessErrorCodes.ValidationFailed) + { + return false; + } + } + } + + // Verify original data is not modified + var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); + return retrieved.PrizeLevel == prizeLevel; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** + /// For any update request where PrizeName is set to empty or whitespace-only, + /// the system should reject the request and return a validation error without modifying the database. + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool RequiredFieldValidation_EmptyPrizeName_ShouldRejectUpdate(PositiveInt seed) + { + var userName = GenerateValidString("User", seed.Get); + var prizeLevel = GenerateValidString("Level", seed.Get); + var prizeName = GenerateValidString("Prize", seed.Get); + + var (dbContext, service) = CreateService(); + + try + { + // First create a valid announcement + var createRequest = new CreateAnnouncementRequest + { + UserName = userName, + PrizeLevel = prizeLevel, + PrizeName = prizeName, + Sort = 0, + IsEnabled = true + }; + + var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); + + var emptyPrizeNames = new[] { "", " ", "\t", "\n" }; + + foreach (var emptyPrizeName in emptyPrizeNames) + { + var updateRequest = new UpdateAnnouncementRequest + { + PrizeName = emptyPrizeName + }; + + try + { + service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult(); + return false; // Should have thrown + } + catch (BusinessException ex) + { + if (ex.Code != BusinessErrorCodes.ValidationFailed) + { + return false; + } + } + } + + // Verify original data is not modified + var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult(); + return retrieved.PrizeName == prizeName; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** + /// For any create request with all required fields empty, + /// the system should reject the request without modifying the database. + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 50)] + public bool RequiredFieldValidation_AllFieldsEmpty_ShouldRejectCreate(PositiveInt seed) + { + var (dbContext, service) = CreateService(); + + try + { + var createRequest = new CreateAnnouncementRequest + { + UserName = "", + PrizeLevel = "", + PrizeName = "", + Sort = 0, + IsEnabled = true + }; + + try + { + service.CreateAsync(createRequest).GetAwaiter().GetResult(); + return false; // Should have thrown + } + catch (BusinessException ex) + { + if (ex.Code != BusinessErrorCodes.ValidationFailed) + { + return false; + } + } + + // Verify database is not modified + var count = dbContext.PrizeAnnouncements.Count(); + return count == 0; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 4: Required Field Validation** + /// Valid requests with non-empty required fields should succeed. + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool RequiredFieldValidation_ValidFields_ShouldSucceed( + NonEmptyString userName, + NonEmptyString prizeLevel, + NonEmptyString prizeName) + { + var actualUserName = userName.Get.Trim(); + var actualPrizeLevel = prizeLevel.Get.Trim(); + var actualPrizeName = prizeName.Get.Trim(); + + // Skip if any required field becomes empty after trim + if (string.IsNullOrWhiteSpace(actualUserName) || + string.IsNullOrWhiteSpace(actualPrizeLevel) || + string.IsNullOrWhiteSpace(actualPrizeName)) + { + return true; // Skip this test case + } + + var (dbContext, service) = CreateService(); + + try + { + var createRequest = new CreateAnnouncementRequest + { + UserName = actualUserName, + PrizeLevel = actualPrizeLevel, + PrizeName = actualPrizeName, + Sort = 0, + IsEnabled = true + }; + + var created = service.CreateAsync(createRequest).GetAwaiter().GetResult(); + + // Verify creation succeeded + return created.Id > 0 && + created.UserName == actualUserName && + created.PrizeLevel == actualPrizeLevel && + created.PrizeName == actualPrizeName; + } + catch (BusinessException) + { + return false; // Should not throw for valid data + } + finally + { + dbContext.Dispose(); + } + } + + #endregion +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/DesignatedPrizeServicePropertyTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/DesignatedPrizeServicePropertyTests.cs index 18e86000..5ff8e68b 100644 --- a/server/HoneyBox/tests/HoneyBox.Tests/Services/DesignatedPrizeServicePropertyTests.cs +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/DesignatedPrizeServicePropertyTests.cs @@ -371,8 +371,6 @@ public class DesignatedPrizeServicePropertyTests } #endregion -} - #region Property 8: Prize Data Immutability @@ -799,3 +797,5 @@ public class DesignatedPrizeServicePropertyTests } #endregion + +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/PrizeAnnouncementServicePropertyTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/PrizeAnnouncementServicePropertyTests.cs new file mode 100644 index 00000000..7ecbc931 --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/PrizeAnnouncementServicePropertyTests.cs @@ -0,0 +1,491 @@ +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Core.Services; +using HoneyBox.Model.Data; +using HoneyBox.Model.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// PrizeAnnouncementService 属性测试(用户端) +/// 测试首页中大奖公告功能的用户端核心属性 +/// +public class PrizeAnnouncementServicePropertyTests +{ + private readonly Mock> _mockLogger = new(); + + private (HoneyBoxDbContext dbContext, PrizeAnnouncementService service) CreateService() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + var dbContext = new HoneyBoxDbContext(options); + var service = new PrizeAnnouncementService(dbContext, _mockLogger.Object); + return (dbContext, service); + } + + /// + /// 生成有效的非空字符串(用于必填字段) + /// + private static string GenerateValidString(string prefix, int seed) + { + return $"{prefix}_{Math.Abs(seed) % 1000}"; + } + + /// + /// 创建测试用的公告实体 + /// + private static PrizeAnnouncement CreateAnnouncement(int seed, bool isEnabled, int sort) + { + return new PrizeAnnouncement + { + UserAvatar = $"http://test.com/avatar_{seed}.jpg", + UserName = GenerateValidString("User", seed), + PrizeLevel = GenerateValidString("Level", seed), + PrizeName = GenerateValidString("Prize", seed), + Sort = sort, + IsEnabled = isEnabled, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + } + + #region Property 2: Enabled Filter Correctness + + /// + /// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness** + /// For any set of announcements with mixed enabled/disabled states, the user-facing API + /// should only return announcements where IsEnabled is true. + /// **Validates: Requirements 2.2, 1.6** + /// + [Property(MaxTest = 100)] + public bool EnabledFilterCorrectness_OnlyReturnsEnabledAnnouncements( + PositiveInt enabledCount, + PositiveInt disabledCount) + { + var actualEnabledCount = Math.Max(1, enabledCount.Get % 10); + var actualDisabledCount = disabledCount.Get % 10; + + var (dbContext, service) = CreateService(); + + try + { + // Create enabled announcements + for (int i = 0; i < actualEnabledCount; i++) + { + var announcement = CreateAnnouncement(i, isEnabled: true, sort: i); + dbContext.PrizeAnnouncements.Add(announcement); + } + + // Create disabled announcements + for (int i = 0; i < actualDisabledCount; i++) + { + var announcement = CreateAnnouncement(i + 1000, isEnabled: false, sort: i + 1000); + dbContext.PrizeAnnouncements.Add(announcement); + } + + dbContext.SaveChanges(); + + // Get enabled announcements via service + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: all returned items should be enabled + // Since we're testing the service, we verify by checking the count matches enabled count + return result.Count == actualEnabledCount; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness** + /// For any set of announcements, the count of returned items should equal + /// the count of enabled announcements in the database. + /// **Validates: Requirements 2.2, 1.6** + /// + [Property(MaxTest = 100)] + public bool EnabledFilterCorrectness_CountMatchesEnabledInDatabase(PositiveInt totalCount) + { + var actualTotalCount = Math.Max(1, totalCount.Get % 20); + var random = new Random(totalCount.Get); + + var (dbContext, service) = CreateService(); + + try + { + var expectedEnabledCount = 0; + + // Create announcements with random enabled states + for (int i = 0; i < actualTotalCount; i++) + { + var isEnabled = random.Next(2) == 1; + if (isEnabled) expectedEnabledCount++; + + var announcement = CreateAnnouncement(i, isEnabled, sort: i); + dbContext.PrizeAnnouncements.Add(announcement); + } + + dbContext.SaveChanges(); + + // Get enabled announcements via service + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: count should match expected enabled count + return result.Count == expectedEnabledCount; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness** + /// When all announcements are disabled, the API should return an empty list. + /// **Validates: Requirements 2.2, 1.6** + /// + [Property(MaxTest = 100)] + public bool EnabledFilterCorrectness_AllDisabled_ReturnsEmptyList(PositiveInt count) + { + var actualCount = Math.Max(1, count.Get % 10); + + var (dbContext, service) = CreateService(); + + try + { + // Create only disabled announcements + for (int i = 0; i < actualCount; i++) + { + var announcement = CreateAnnouncement(i, isEnabled: false, sort: i); + dbContext.PrizeAnnouncements.Add(announcement); + } + + dbContext.SaveChanges(); + + // Get enabled announcements via service + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: should return empty list + return result.Count == 0; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness** + /// When all announcements are enabled, the API should return all of them. + /// **Validates: Requirements 2.2, 1.6** + /// + [Property(MaxTest = 100)] + public bool EnabledFilterCorrectness_AllEnabled_ReturnsAll(PositiveInt count) + { + var actualCount = Math.Max(1, count.Get % 10); + + var (dbContext, service) = CreateService(); + + try + { + // Create only enabled announcements + for (int i = 0; i < actualCount; i++) + { + var announcement = CreateAnnouncement(i, isEnabled: true, sort: i); + dbContext.PrizeAnnouncements.Add(announcement); + } + + dbContext.SaveChanges(); + + // Get enabled announcements via service + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: should return all announcements + return result.Count == actualCount; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness** + /// When database is empty, the API should return an empty list. + /// **Validates: Requirements 2.2, 1.6** + /// + [Fact] + public void EnabledFilterCorrectness_EmptyDatabase_ReturnsEmptyList() + { + var (dbContext, service) = CreateService(); + + try + { + // Get enabled announcements from empty database + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: should return empty list + Assert.Empty(result); + } + finally + { + dbContext.Dispose(); + } + } + + #endregion + + #region Property 3: Sort Ordering Preservation + + /// + /// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation** + /// For any set of enabled announcements with different sort values, the user-facing API + /// should return them in ascending order by the sort field. + /// **Validates: Requirements 2.3, 1.7** + /// + [Property(MaxTest = 100)] + public bool SortOrderingPreservation_ReturnsInAscendingOrder(PositiveInt count, PositiveInt seed) + { + var actualCount = Math.Max(2, count.Get % 10 + 2); + var random = new Random(seed.Get); + + var (dbContext, service) = CreateService(); + + try + { + // Create announcements with random sort values + var sortValues = Enumerable.Range(0, actualCount) + .Select(_ => random.Next(1000)) + .ToList(); + + for (int i = 0; i < actualCount; i++) + { + var announcement = CreateAnnouncement(i, isEnabled: true, sort: sortValues[i]); + dbContext.PrizeAnnouncements.Add(announcement); + } + + dbContext.SaveChanges(); + + // Get enabled announcements via service + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: for any two consecutive items, first item's sort should be <= second item's sort + // Since DTO doesn't have Sort field, we verify by checking the order matches expected + var expectedOrder = sortValues.OrderBy(s => s).ToList(); + + // We need to verify the order by checking the UserName pattern which contains the index + // The announcements should be returned in the order of their sort values + for (int i = 0; i < result.Count - 1; i++) + { + // Extract the original index from UserName (format: "User_{index}") + var currentIndex = int.Parse(result[i].UserName.Split('_')[1]); + var nextIndex = int.Parse(result[i + 1].UserName.Split('_')[1]); + + var currentSort = sortValues[currentIndex]; + var nextSort = sortValues[nextIndex]; + + if (currentSort > nextSort) + { + return false; + } + } + + return true; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation** + /// For any two consecutive items in the result, the first item's sort value + /// should be less than or equal to the second item's sort value. + /// **Validates: Requirements 2.3, 1.7** + /// + [Property(MaxTest = 100)] + public bool SortOrderingPreservation_ConsecutiveItemsOrdered(PositiveInt count) + { + var actualCount = Math.Max(2, count.Get % 15 + 2); + + var (dbContext, service) = CreateService(); + + try + { + // Create announcements with sequential sort values in reverse order + // This tests that the service correctly orders them + for (int i = 0; i < actualCount; i++) + { + var announcement = CreateAnnouncement(i, isEnabled: true, sort: actualCount - i); + dbContext.PrizeAnnouncements.Add(announcement); + } + + dbContext.SaveChanges(); + + // Get enabled announcements via service + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: items should be in ascending order by sort + // The first item should have sort = 1, last should have sort = actualCount + for (int i = 0; i < result.Count - 1; i++) + { + var currentIndex = int.Parse(result[i].UserName.Split('_')[1]); + var nextIndex = int.Parse(result[i + 1].UserName.Split('_')[1]); + + var currentSort = actualCount - currentIndex; + var nextSort = actualCount - nextIndex; + + if (currentSort > nextSort) + { + return false; + } + } + + return true; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation** + /// Announcements with the same sort value should be returned (order among them is stable). + /// **Validates: Requirements 2.3, 1.7** + /// + [Property(MaxTest = 100)] + public bool SortOrderingPreservation_SameSortValues_AllReturned(PositiveInt count) + { + var actualCount = Math.Max(2, count.Get % 10 + 2); + + var (dbContext, service) = CreateService(); + + try + { + // Create announcements with the same sort value + for (int i = 0; i < actualCount; i++) + { + var announcement = CreateAnnouncement(i, isEnabled: true, sort: 100); + dbContext.PrizeAnnouncements.Add(announcement); + } + + dbContext.SaveChanges(); + + // Get enabled announcements via service + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: all announcements should be returned + return result.Count == actualCount; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation** + /// Only enabled announcements should be sorted and returned. + /// **Validates: Requirements 2.3, 1.7** + /// + [Property(MaxTest = 100)] + public bool SortOrderingPreservation_OnlyEnabledAreSorted( + PositiveInt enabledCount, + PositiveInt disabledCount) + { + var actualEnabledCount = Math.Max(2, enabledCount.Get % 10 + 2); + var actualDisabledCount = disabledCount.Get % 5; + + var (dbContext, service) = CreateService(); + + try + { + // Create enabled announcements with specific sort values + for (int i = 0; i < actualEnabledCount; i++) + { + var announcement = CreateAnnouncement(i, isEnabled: true, sort: actualEnabledCount - i); + dbContext.PrizeAnnouncements.Add(announcement); + } + + // Create disabled announcements with sort values that would appear first if included + for (int i = 0; i < actualDisabledCount; i++) + { + var announcement = CreateAnnouncement(i + 1000, isEnabled: false, sort: 0); + dbContext.PrizeAnnouncements.Add(announcement); + } + + dbContext.SaveChanges(); + + // Get enabled announcements via service + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: only enabled announcements are returned and sorted + if (result.Count != actualEnabledCount) + { + return false; + } + + // Verify ordering + for (int i = 0; i < result.Count - 1; i++) + { + var currentIndex = int.Parse(result[i].UserName.Split('_')[1]); + var nextIndex = int.Parse(result[i + 1].UserName.Split('_')[1]); + + var currentSort = actualEnabledCount - currentIndex; + var nextSort = actualEnabledCount - nextIndex; + + if (currentSort > nextSort) + { + return false; + } + } + + return true; + } + finally + { + dbContext.Dispose(); + } + } + + /// + /// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation** + /// Single announcement should be returned correctly. + /// **Validates: Requirements 2.3, 1.7** + /// + [Property(MaxTest = 100)] + public bool SortOrderingPreservation_SingleAnnouncement_ReturnsCorrectly(PositiveInt sort) + { + var actualSort = sort.Get % 1000; + + var (dbContext, service) = CreateService(); + + try + { + // Create single enabled announcement + var announcement = CreateAnnouncement(0, isEnabled: true, sort: actualSort); + dbContext.PrizeAnnouncements.Add(announcement); + dbContext.SaveChanges(); + + // Get enabled announcements via service + var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult(); + + // Verify: single announcement is returned + return result.Count == 1 && result[0].UserName == "User_0"; + } + finally + { + dbContext.Dispose(); + } + } + + #endregion +}