package com.yunhai.cms.service.impl; import com.yunhai.cms.entity.CmsArticle; import com.yunhai.cms.entity.CmsBanner; import com.yunhai.cms.enums.ArticleStatusEnum; import com.yunhai.cms.enums.CategoryTypeEnum; import com.yunhai.cms.model.dto.ArticleSaveRequest; import com.yunhai.cms.model.dto.BannerSaveRequest; import com.yunhai.cms.model.dto.CategorySaveRequest; import com.yunhai.cms.model.dto.DeleteRequest; import com.yunhai.cms.support.CmsIntegrationTestBase; import com.yunhai.cms.support.TestUsers; import com.yunhai.platform.apistd.BizException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * 脏数据健壮性回归(第 2 步 bug 清单 D-3/D-4/D-5/D-6): * 非法枚举码显式拒绝、定时时间窗校验、删除时轮播引用拦截(统一"被引用即拦"规则)。 */ @DisplayName("脏数据校验 - 枚举/时间窗/删除引用拦截") @Transactional class DirtyValidationIT extends CmsIntegrationTestBase { @Autowired private ArticleService articleService; @Autowired private CategoryService categoryService; @Autowired private BannerService bannerService; private Integer siteId; private Integer categoryId; @BeforeEach void seed() { siteId = persistSite("dirty1", 1, "脏数据演示站").getId(); categoryId = persistCategory(siteId, 0, "新闻", CategoryTypeEnum.LIST).getId(); } private CategorySaveRequest catReq(Integer type) { CategorySaveRequest r = new CategorySaveRequest(); r.setSiteId(siteId); r.setParentId(0); r.setName("新栏目"); r.setType(type); return r; } // ===== D-4 非法枚举码 ===== @Test @DisplayName("栏目 type=9 显式拒绝(不再静默降级为列表栏目)") void categorySave_illegalType_rejected() { CategorySaveRequest r = catReq(9); assertThatThrownBy(() -> categoryService.save(TestUsers.superAdmin(), r)) .isInstanceOf(BizException.class) .hasMessageContaining("栏目类型非法"); } @Test @DisplayName("轮播 linkType=9 显式拒绝(不再误报\"跳转目标必须是数字 ID\")") void bannerSave_illegalLinkType_rejected() { BannerSaveRequest r = new BannerSaveRequest(); r.setSiteId(siteId); r.setPosition("home"); r.setTitle("t"); r.setImageUrl("/upload/x.png"); r.setLinkType(9); r.setLinkValue("https://example.cn"); assertThatThrownBy(() -> bannerService.save(TestUsers.superAdmin(), r)) .isInstanceOf(BizException.class) .hasMessageContaining("跳转类型非法"); } // ===== D-5 定时时间窗 ===== @Test @DisplayName("保存:offlineTime 早于 publishTime 显式拒绝(状态机自洽)") void articleSave_offlineBeforePublish_rejected() { ArticleSaveRequest r = new ArticleSaveRequest(); r.setSiteId(siteId); r.setCategoryId(categoryId); r.setTitle("时间矛盾"); r.setPublishTime(LocalDateTime.of(2026, 12, 1, 0, 0)); r.setOfflineTime(LocalDateTime.of(2026, 11, 1, 0, 0)); assertThatThrownBy(() -> articleService.save(TestUsers.superAdmin(), r)) .isInstanceOf(BizException.class) .hasMessageContaining("定时下架时间必须晚于发布时间"); } @Test @DisplayName("发布:过期 offlineTime 自愈清空(重发=新生命周期,不再被调度器立即下架)") void publish_staleOfflineTime_cleared() { Integer id = articleService.save(TestUsers.superAdmin(), draftReq()); // 直接落库一个矛盾历史数据(模拟脏数据):offlineTime 已过 articleRepository.findById(id).ifPresent(a -> { a.setOfflineTime(LocalDateTime.now().minusDays(1)); articleRepository.saveAndFlush(a); }); articleService.publish(TestUsers.superAdmin(), siteId, id); CmsArticle after = articleRepository.findById(id).orElseThrow(IllegalStateException::new); assertThat(after.getStatus()).isEqualTo(ArticleStatusEnum.PUBLISHED.getCode()); assertThat(after.getOfflineTime()).isNull(); } private ArticleSaveRequest draftReq() { ArticleSaveRequest r = new ArticleSaveRequest(); r.setSiteId(siteId); r.setCategoryId(categoryId); r.setTitle("普通草稿"); r.setContent("

正文

"); return r; } // ===== D-6 删除时轮播引用拦截 ===== @Test @DisplayName("删除文章:被轮播引用时拦截并提示") void articleDelete_blockedByBannerReference() { Integer articleId = articleService.save(TestUsers.superAdmin(), draftReq()); persistBannerRef(1, String.valueOf(articleId)); DeleteRequest req = new DeleteRequest(); req.setSiteId(siteId); req.setId(articleId); assertThatThrownBy(() -> articleService.delete(TestUsers.superAdmin(), req)) .isInstanceOf(BizException.class) .hasMessageContaining("轮播"); } @Test @DisplayName("删除栏目:被轮播引用时拦截并提示(与子栏目/文章拦截同一规则)") void categoryDelete_blockedByBannerReference() { persistBannerRef(2, String.valueOf(categoryId)); DeleteRequest req = new DeleteRequest(); req.setSiteId(siteId); req.setId(categoryId); assertThatThrownBy(() -> categoryService.delete(TestUsers.superAdmin(), req)) .isInstanceOf(BizException.class) .hasMessageContaining("轮播"); } @Test @DisplayName("删除文章:无轮播引用时正常删除(不误拦)") void articleDelete_withoutReference_succeeds() { Integer articleId = articleService.save(TestUsers.superAdmin(), draftReq()); DeleteRequest req = new DeleteRequest(); req.setSiteId(siteId); req.setId(articleId); articleService.delete(TestUsers.superAdmin(), req); assertThat(articleRepository.findById(articleId)).isEmpty(); } private void persistBannerRef(int linkType, String linkValue) { CmsBanner b = new CmsBanner(); b.setSiteId(siteId); b.setPosition("home"); b.setTitle("引用型轮播"); b.setImageUrl("/upload/banner.png"); b.setLinkType(linkType); b.setLinkValue(linkValue); b.setSortNo(1); b.setStatus(1); bannerRepository.saveAndFlush(b); } }