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.Assessment; using MiAssessment.Admin.Business.Models.Common; using MiAssessment.Admin.Business.Services; using Moq; using Xunit; namespace MiAssessment.Tests.Admin; /// /// Mapping 属性测试 /// 验证题目分类映射服务的正确性属性 /// public class MappingPropertyTests { private readonly Mock> _mockLogger = new(); #region Property 10: Mapping Relationship Bidirectionality /// /// Property 10: 通过题目ID查询映射应包含关联的分类ID /// *For any* QuestionCategoryMapping, querying mappings by QuestionId SHALL include the CategoryId. /// /// **Validates: Requirements 7.1** /// [Property(MaxTest = 100)] public bool Bidirectionality_QueryByQuestion_ShouldIncludeCategoryId(PositiveInt seed) { // Arrange: 创建题目、分类和映射关系 using var dbContext = CreateDbContext(); var (assessmentTypeId, questionId, categoryId) = CreateTestData(dbContext, seed.Get); // 创建映射关系 var mapping = new QuestionCategoryMapping { QuestionId = questionId, CategoryId = categoryId, CreateTime = DateTime.Now }; dbContext.QuestionCategoryMappings.Add(mapping); dbContext.SaveChanges(); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 通过题目ID查询映射 var categories = service.GetMappingsByQuestionAsync(questionId).GetAwaiter().GetResult(); // Assert: 应该包含关联的分类ID return categories.Any(c => c.Id == categoryId); } /// /// Property 10: 通过分类ID查询映射应包含关联的题目ID /// *For any* QuestionCategoryMapping, querying mappings by CategoryId SHALL include the QuestionId. /// /// **Validates: Requirements 7.2** /// [Property(MaxTest = 100)] public bool Bidirectionality_QueryByCategory_ShouldIncludeQuestionId(PositiveInt seed) { // Arrange: 创建题目、分类和映射关系 using var dbContext = CreateDbContext(); var (assessmentTypeId, questionId, categoryId) = CreateTestData(dbContext, seed.Get); // 创建映射关系 var mapping = new QuestionCategoryMapping { QuestionId = questionId, CategoryId = categoryId, CreateTime = DateTime.Now }; dbContext.QuestionCategoryMappings.Add(mapping); dbContext.SaveChanges(); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 通过分类ID查询映射 var questions = service.GetMappingsByCategoryAsync(categoryId).GetAwaiter().GetResult(); // Assert: 应该包含关联的题目ID return questions.Any(q => q.Id == questionId); } /// /// Property 10: 双向查询应返回一致的映射关系 /// 如果通过题目查询到分类A,则通过分类A查询应能找到该题目 /// /// **Validates: Requirements 7.1, 7.2** /// [Property(MaxTest = 100)] public bool Bidirectionality_ConsistentBothDirections(PositiveInt seed, PositiveInt categoryCount) { // 限制分类数量为1-5 var actualCategoryCount = (categoryCount.Get % 5) + 1; // Arrange: 创建题目和多个分类 using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get); var categoryIds = new List(); for (int i = 0; i < actualCategoryCount; i++) { var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get + i); categoryIds.Add(categoryId); // 创建映射关系 var mapping = new QuestionCategoryMapping { QuestionId = questionId, CategoryId = categoryId, CreateTime = DateTime.Now }; dbContext.QuestionCategoryMappings.Add(mapping); } dbContext.SaveChanges(); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 通过题目查询分类 var categoriesFromQuestion = service.GetMappingsByQuestionAsync(questionId).GetAwaiter().GetResult(); // Assert: 对于每个查询到的分类,反向查询应能找到原题目 foreach (var category in categoriesFromQuestion) { var questionsFromCategory = service.GetMappingsByCategoryAsync(category.Id).GetAwaiter().GetResult(); if (!questionsFromCategory.Any(q => q.Id == questionId)) { return false; } } return true; } /// /// Property 10: 多对多映射关系的双向一致性 /// 多个题目关联同一分类时,通过分类查询应返回所有关联的题目 /// /// **Validates: Requirements 7.1, 7.2** /// [Property(MaxTest = 50)] public bool Bidirectionality_ManyToMany_Consistency(PositiveInt seed, PositiveInt questionCount) { // 限制题目数量为2-5 var actualQuestionCount = (questionCount.Get % 4) + 2; // Arrange: 创建多个题目和一个分类 using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get); var questionIds = new List(); for (int i = 0; i < actualQuestionCount; i++) { var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get + i); questionIds.Add(questionId); // 创建映射关系 var mapping = new QuestionCategoryMapping { QuestionId = questionId, CategoryId = categoryId, CreateTime = DateTime.Now }; dbContext.QuestionCategoryMappings.Add(mapping); } dbContext.SaveChanges(); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 通过分类查询题目 var questionsFromCategory = service.GetMappingsByCategoryAsync(categoryId).GetAwaiter().GetResult(); // Assert: 应该返回所有关联的题目 if (questionsFromCategory.Count != actualQuestionCount) return false; foreach (var questionId in questionIds) { if (!questionsFromCategory.Any(q => q.Id == questionId)) { return false; } } return true; } /// /// Property 10: 软删除的题目不应出现在分类的映射查询结果中 /// /// **Validates: Requirements 7.2** /// [Property(MaxTest = 100)] public bool Bidirectionality_DeletedQuestionNotInCategoryQuery(PositiveInt seed) { // Arrange: 创建题目、分类和映射关系 using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get); // 创建正常题目 var normalQuestionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get); dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping { QuestionId = normalQuestionId, CategoryId = categoryId, CreateTime = DateTime.Now }); // 创建已删除的题目 var deletedQuestion = new Question { AssessmentTypeId = assessmentTypeId, QuestionNo = seed.Get + 1000, Content = $"Deleted Question {seed.Get}", Sort = 1, Status = 1, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsDeleted = true // 已软删除 }; dbContext.Questions.Add(deletedQuestion); dbContext.SaveChanges(); dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping { QuestionId = deletedQuestion.Id, CategoryId = categoryId, CreateTime = DateTime.Now }); dbContext.SaveChanges(); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 通过分类查询题目 var questions = service.GetMappingsByCategoryAsync(categoryId).GetAwaiter().GetResult(); // Assert: 只应该返回正常的题目,不应包含已删除的题目 return questions.Count == 1 && questions[0].Id == normalQuestionId; } /// /// Property 10: 软删除的分类不应出现在题目的映射查询结果中 /// /// **Validates: Requirements 7.1** /// [Property(MaxTest = 100)] public bool Bidirectionality_DeletedCategoryNotInQuestionQuery(PositiveInt seed) { // Arrange: 创建题目和分类 using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get); // 创建正常分类 var normalCategoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get); dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping { QuestionId = questionId, CategoryId = normalCategoryId, CreateTime = DateTime.Now }); // 创建已删除的分类 var deletedCategory = new ReportCategory { AssessmentTypeId = assessmentTypeId, ParentId = 0, Name = $"Deleted Category {seed.Get}", Code = $"DELETED_{seed.Get}_{Guid.NewGuid():N}".Substring(0, 20), CategoryType = 1, ScoreRule = 1, Sort = 1, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsDeleted = true // 已软删除 }; dbContext.ReportCategories.Add(deletedCategory); dbContext.SaveChanges(); dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping { QuestionId = questionId, CategoryId = deletedCategory.Id, CreateTime = DateTime.Now }); dbContext.SaveChanges(); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 通过题目查询分类 var categories = service.GetMappingsByQuestionAsync(questionId).GetAwaiter().GetResult(); // Assert: 只应该返回正常的分类,不应包含已删除的分类 return categories.Count == 1 && categories[0].Id == normalCategoryId; } #endregion #region Property 11: Batch Operation Atomicity /// /// Property 11: 批量更新成功时所有映射都应被更新 /// *For any* batch update of question-category mappings, all mappings SHALL be updated successfully. /// /// **Validates: Requirements 7.6** /// [Property(MaxTest = 100)] public bool Atomicity_SuccessfulUpdate_AllMappingsUpdated(PositiveInt seed, PositiveInt categoryCount) { // 限制分类数量为1-5 var actualCategoryCount = (categoryCount.Get % 5) + 1; // Arrange: 创建题目和多个分类 using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get); var categoryIds = new List(); for (int i = 0; i < actualCategoryCount; i++) { var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get + i); categoryIds.Add(categoryId); } var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 批量更新映射 var request = new BatchUpdateMappingsRequest { QuestionId = questionId, CategoryIds = categoryIds }; try { var result = service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult(); if (!result) return false; // Assert: 验证所有映射都已创建 var mappings = dbContext.QuestionCategoryMappings .Where(m => m.QuestionId == questionId) .ToList(); if (mappings.Count != actualCategoryCount) return false; foreach (var categoryId in categoryIds) { if (!mappings.Any(m => m.CategoryId == categoryId)) { return false; } } return true; } catch (BusinessException) { return false; } } /// /// Property 11: 批量更新应替换所有现有映射 /// /// **Validates: Requirements 7.6** /// [Property(MaxTest = 100)] public bool Atomicity_UpdateReplacesExistingMappings(PositiveInt seed) { // Arrange: 创建题目和分类,并创建初始映射 using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get); // 创建初始分类和映射 var oldCategoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get); dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping { QuestionId = questionId, CategoryId = oldCategoryId, CreateTime = DateTime.Now }); dbContext.SaveChanges(); // 创建新分类 var newCategoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get + 100); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 批量更新映射(用新分类替换旧分类) var request = new BatchUpdateMappingsRequest { QuestionId = questionId, CategoryIds = new List { newCategoryId } }; try { var result = service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult(); if (!result) return false; // Assert: 验证旧映射已删除,新映射已创建 var mappings = dbContext.QuestionCategoryMappings .Where(m => m.QuestionId == questionId) .ToList(); // 应该只有一个映射(新的) if (mappings.Count != 1) return false; if (mappings[0].CategoryId != newCategoryId) return false; // 旧映射应该不存在 var oldMappingExists = dbContext.QuestionCategoryMappings .Any(m => m.QuestionId == questionId && m.CategoryId == oldCategoryId); return !oldMappingExists; } catch (BusinessException) { return false; } } /// /// Property 11: 批量更新失败时不应有任何映射被更新(回滚) /// 当提供无效的分类ID时,所有操作应回滚 /// /// **Validates: Requirements 7.6** /// [Property(MaxTest = 100)] public bool Atomicity_FailedUpdate_NoMappingsChanged(PositiveInt seed) { // Arrange: 创建题目和分类,并创建初始映射 using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get); // 创建初始分类和映射 var existingCategoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get); dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping { QuestionId = questionId, CategoryId = existingCategoryId, CreateTime = DateTime.Now }); dbContext.SaveChanges(); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 尝试批量更新映射(包含无效的分类ID) var request = new BatchUpdateMappingsRequest { QuestionId = questionId, CategoryIds = new List { existingCategoryId, 999999 } // 999999 是无效的分类ID }; try { service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult(); return false; // 应该抛出异常 } catch (BusinessException ex) { // Assert: 验证原有映射未被修改 if (ex.Code != ErrorCodes.CategoryNotFound) return false; var mappings = dbContext.QuestionCategoryMappings .Where(m => m.QuestionId == questionId) .ToList(); // 原有映射应该保持不变 return mappings.Count == 1 && mappings[0].CategoryId == existingCategoryId; } } /// /// Property 11: 空分类列表应清除所有映射 /// /// **Validates: Requirements 7.5, 7.6** /// [Property(MaxTest = 100)] public bool Atomicity_EmptyCategoryList_ClearsAllMappings(PositiveInt seed) { // Arrange: 创建题目和分类,并创建初始映射 using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get); // 创建多个初始映射 for (int i = 0; i < 3; i++) { var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get + i); dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping { QuestionId = questionId, CategoryId = categoryId, CreateTime = DateTime.Now }); } dbContext.SaveChanges(); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 批量更新映射(空分类列表) var request = new BatchUpdateMappingsRequest { QuestionId = questionId, CategoryIds = new List() // 空列表 }; try { var result = service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult(); if (!result) return false; // Assert: 验证所有映射都已删除 var mappings = dbContext.QuestionCategoryMappings .Where(m => m.QuestionId == questionId) .ToList(); return mappings.Count == 0; } catch (BusinessException) { return false; } } /// /// Property 11: 批量更新应去重分类ID /// /// **Validates: Requirements 7.4, 7.6** /// [Property(MaxTest = 100)] public bool Atomicity_DuplicateCategoryIds_ShouldBeDeduplicated(PositiveInt seed) { // Arrange: 创建题目和分类 using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get); var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 批量更新映射(包含重复的分类ID) var request = new BatchUpdateMappingsRequest { QuestionId = questionId, CategoryIds = new List { categoryId, categoryId, categoryId } // 重复的分类ID }; try { var result = service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult(); if (!result) return false; // Assert: 验证只创建了一个映射(去重后) var mappings = dbContext.QuestionCategoryMappings .Where(m => m.QuestionId == questionId) .ToList(); return mappings.Count == 1 && mappings[0].CategoryId == categoryId; } catch (BusinessException) { return false; } } /// /// Property 11: 对不存在的题目进行批量更新应失败 /// /// **Validates: Requirements 7.3, 7.6** /// [Property(MaxTest = 100)] public bool Atomicity_InvalidQuestionId_ShouldFail(PositiveInt seed) { // Arrange using var dbContext = CreateDbContext(); var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get); var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get); var service = new AssessmentService(dbContext, _mockLogger.Object); // Act: 尝试对不存在的题目进行批量更新 var request = new BatchUpdateMappingsRequest { QuestionId = 999999, // 不存在的题目ID CategoryIds = new List { categoryId } }; try { service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult(); return false; // 应该抛出异常 } catch (BusinessException ex) { return ex.Code == ErrorCodes.QuestionNotFound; } } #endregion #region 辅助方法 /// /// 创建内存数据库上下文 /// private AdminBusinessDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning)) .Options; return new AdminBusinessDbContext(options); } /// /// 创建测评类型并返回ID /// private long CreateAssessmentType(AdminBusinessDbContext dbContext, int seed) { var assessmentType = new AssessmentType { Name = $"Test Assessment {seed}", Code = $"TEST_{seed}_{Guid.NewGuid():N}".Substring(0, 20), Price = 99.99m, QuestionCount = 80, Sort = 1, Status = 1, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsDeleted = false }; dbContext.AssessmentTypes.Add(assessmentType); dbContext.SaveChanges(); return assessmentType.Id; } /// /// 创建题目并返回ID /// private long CreateQuestion(AdminBusinessDbContext dbContext, long assessmentTypeId, int seed) { var question = new Question { AssessmentTypeId = assessmentTypeId, QuestionNo = seed, Content = $"Test Question {seed}", Sort = 1, Status = 1, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsDeleted = false }; dbContext.Questions.Add(question); dbContext.SaveChanges(); return question.Id; } /// /// 创建分类并返回ID /// private long CreateCategory(AdminBusinessDbContext dbContext, long assessmentTypeId, int seed) { var category = new ReportCategory { AssessmentTypeId = assessmentTypeId, ParentId = 0, Name = $"Test Category {seed}", Code = $"CAT_{seed}_{Guid.NewGuid():N}".Substring(0, 20), CategoryType = (seed % 8) + 1, ScoreRule = (seed % 2) + 1, Sort = 1, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, IsDeleted = false }; dbContext.ReportCategories.Add(category); dbContext.SaveChanges(); return category.Id; } /// /// 创建测试数据(测评类型、题目、分类) /// private (long assessmentTypeId, long questionId, long categoryId) CreateTestData( AdminBusinessDbContext dbContext, int seed) { var assessmentTypeId = CreateAssessmentType(dbContext, seed); var questionId = CreateQuestion(dbContext, assessmentTypeId, seed); var categoryId = CreateCategory(dbContext, assessmentTypeId, seed); return (assessmentTypeId, questionId, categoryId); } #endregion }