package com.yunhai.cms.svcitem; import com.yunhai.cms.support.CmsIntegrationTestBase; import com.yunhai.cms.svcitem.enums.ServiceItemStatusEnum; import org.junit.jupiter.api.AfterEach; 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.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * 门户服务中心/事项指南集成测试(事项库详细设计 §5/§6 + M0 回退 IT 0.3): * 路由双变体、301 兼容、404 语义、筛选与分页 URL 预生成(C-2)、富文本补基准、 * applyUrl 三态、可选模板整页回退(缺模板主题的资产段退 default)。 */ @DisplayName("SvcItemPortal - 门户公开层(L2)") class SvcItemPortalIT extends CmsIntegrationTestBase { @Autowired private MockMvc mockMvc; @Autowired private com.yunhai.cms.svcitem.service.SvcItemQuery svcItemQuery; private Integer siteId; @BeforeEach void seed() { purgeTables(); siteId = persistSite("demo1", 1, "演示站点一").getId(); persistSvcDict(siteId, "object", "person", "个人", 1); persistSvcDict(siteId, "object", "legal", "法人", 1); persistSvcDict(siteId, "category", "jw", "教务服务", 1); } @AfterEach void clean() { purgeTables(); } private Integer publishItem(String code, String title, String objectKey, String categoryKey) { com.yunhai.cms.svcitem.entity.CmsServiceItem item = persistSvcItem(siteId, code, title, ServiceItemStatusEnum.PUBLISHED); item.setObjectKey(objectKey); item.setCategoryKey(categoryKey); item.setDeptName("教务处"); return serviceItemRepository.saveAndFlush(item).getId(); } private String body(MvcResult result) throws java.io.UnsupportedEncodingException { return result.getResponse().getContentAsString(StandardCharsets.UTF_8); } // ===== 路由(§5.1:单段 + 嵌套双变体、301、404)===== @Test @DisplayName("路由双变体:/portal/{code}/service 与 /portal/{p}/{s}/service 均 200") void routes_singleAndNested() throws Exception { publishItem("S1", "转学办理", "person", "jw"); assertThat(body(mockMvc.perform(get("/portal/demo1/service")) .andExpect(status().isOk()).andReturn())) .contains("服务中心").contains("转学办理"); Integer parent = persistSite("schx", 1, "主站").getId(); Integer childId = persistSite("child", 1, "子站", parent).getId(); com.yunhai.cms.svcitem.entity.CmsServiceItem child = persistSvcItem(childId, "C1", "子站事项", ServiceItemStatusEnum.PUBLISHED); serviceItemRepository.saveAndFlush(child); assertThat(body(mockMvc.perform(get("/portal/schx/child/service")) .andExpect(status().isOk()).andReturn())) .contains("服务中心").contains("子站事项"); } @Test @DisplayName("旧单段 code 访问子站服务页 301 到嵌套形态(保留子路径与 query)") void routes_legacySingleSegmentRedirects301() throws Exception { Integer parent = persistSite("schy", 1, "主站Y").getId(); persistSite("childy", 1, "子站Y", parent); // query 串需走 URI 模板直传(MockMvc param() 不进 getQueryString(),真实请求无此差异) mockMvc.perform(get("/portal/childy/service?obj=person")) .andExpect(status().isMovedPermanently()) .andExpect(header().string("Location", "/portal/schy/childy/service?obj=person")); } @Test @DisplayName("指南页路由:存在且已发布 200;不存在/草稿 404;响应头带 Cache-Control") void guideItem_statusSemantics() throws Exception { Integer id = publishItem("S1", "转学办理", "person", "jw"); mockMvc.perform(get("/portal/demo1/sv/{id}", id)) .andExpect(status().isOk()) .andExpect(header().string("Cache-Control", "max-age=300")); mockMvc.perform(get("/portal/demo1/sv/{id}", 999999)) .andExpect(status().isNotFound()); Integer draft = persistSvcItem(siteId, "S2", "草稿事项", ServiceItemStatusEnum.DRAFT).getId(); mockMvc.perform(get("/portal/demo1/sv/{id}", draft)) .andExpect(status().isNotFound()); } // ===== M0 整页回退(§4.2/§4.3:缺可选模板的主题整页退 default,资产段同步退)===== @Test @DisplayName("M0: 无 service.html 的主题访问服务中心 → 200 且资产 URL 主题段为 default(防 default HTML 配他家 CSS)") void fallback_themeWithoutServiceTemplateFallsBackWholePage() throws Exception { publishItem("S1", "转学办理", "person", "jw"); // t-demo-red 为测试夹具包:注册成功(必备四页齐全)但无 service.html/guide.html com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new); site.setTheme("t-demo-red"); siteRepository.saveAndFlush(site); clearCaches(); String html = body(mockMvc.perform(get("/portal/demo1/service")) .andExpect(status().isOk()).andReturn()); assertThat(html).contains("服务中心").contains("转学办理"); assertThat(html).contains("/assets/default/css/main.css"); assertThat(html).doesNotContain("/assets/t-demo-red/"); Integer id = serviceItemRepository.findBySiteIdAndItemCode(siteId, "S1") .orElseThrow(IllegalStateException::new).getId(); String guideHtml = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id)) .andExpect(status().isOk()).andReturn()); assertThat(guideHtml).contains("/assets/default/css/main.css"); assertThat(guideHtml).doesNotContain("/assets/t-demo-red/"); } // ===== 列表筛选与分页 URL 预生成(C-2,§5.4)===== @Test @DisplayName("三维筛选:计数正确、facet URL 后端预生成(勾选带参/取消去参),模板零拼 query") void serviceList_filtersAndFacetUrls() throws Exception { publishItem("S1", "转学办理", "person", "jw"); publishItem("S2", "休学办理", "person", "jw"); publishItem("S3", "法人年检", "legal", "jw"); String html = body(mockMvc.perform(get("/portal/demo1/service")) .andExpect(status().isOk()).andReturn()); assertThat(html).contains("个人").contains("法人").contains("教务服务"); // 未勾选态 facet URL:带本维参数(page 归 1,无 page 参数) assertThat(html).contains("/portal/demo1/service?obj=person\">"); assertThat(html).contains("/portal/demo1/service?obj=legal\">"); // 勾选 obj=person 后:仅个人事项;取消链接回到无参基址;cat facet URL 保留 obj 维 String filtered = body(mockMvc.perform(get("/portal/demo1/service") .param("obj", "person")) .andExpect(status().isOk()).andReturn()); assertThat(filtered).contains("转学办理").contains("休学办理"); assertThat(filtered).doesNotContain("法人年检"); assertThat(filtered).contains("/portal/demo1/service\">"); assertThat(filtered).contains("/portal/demo1/service?obj=person&cat=jw\">"); assertThat(filtered).contains("清除筛选"); } @Test @DisplayName("分页 URL 预生成:携带当前筛选与关键词(编码),第 2 页起带 page 参数") void serviceList_paginationUrlsCarryFilters() throws Exception { for (int i = 1; i <= 12; i++) { publishItem("S" + i, "事项" + String.format("%02d", i), "person", "jw"); } String html = body(mockMvc.perform(get("/portal/demo1/service") .param("obj", "person").param("q", "事项")) .andExpect(status().isOk()).andReturn()); // 第 2 页链接保留 obj + 编码后的 q,并追加 page=2(链接组装顺序 = obj, q, page) assertThat(html).contains("obj=person&q=%E4%BA%8B%E9%A1%B9&page=2"); // 第 1 页(active)不带 page 参数 assertThat(html).doesNotContain("page=1\""); } @Test @DisplayName("空结果与空站点:优雅空态不 500") void serviceList_emptyState() throws Exception { String html = body(mockMvc.perform(get("/portal/demo1/service")) .andExpect(status().isOk()).andReturn()); assertThat(html).contains("暂未上架"); } @Test @DisplayName("关键词命中标题与摘要;canonical 预生成;嵌套指南页路由可用") void serviceList_keywordCanonicalAndNestedGuide() throws Exception { Integer id = publishItem("S1", "转学办理", "person", "jw"); com.yunhai.cms.svcitem.entity.CmsServiceItem item = serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new); item.setSummary("适用于在校学生转学申请"); serviceItemRepository.saveAndFlush(item); publishItem("S2", "休学办理", "person", "jw"); String html = body(mockMvc.perform(get("/portal/demo1/service").param("q", "在校学生")) .andExpect(status().isOk()).andReturn()); assertThat(html).as("关键词命中摘要").contains("转学办理").doesNotContain("休学办理"); assertThat(html).contains("rel=\"canonical\"").contains("/portal/demo1/service"); // 嵌套指南页路由(设计 §5.1 成对路由:此前只测了嵌套列表页) Integer parent = persistSite("schg", 1, "主站G").getId(); Integer childId = persistSite("childg", 1, "子站G", parent).getId(); com.yunhai.cms.svcitem.entity.CmsServiceItem childItem = persistSvcItem(childId, "C1", "子站事项", ServiceItemStatusEnum.PUBLISHED); childItem.setProcessHtml("

子站流程

"); serviceItemRepository.saveAndFlush(childItem); assertThat(body(mockMvc.perform(get("/portal/schg/childg/sv/{id}", childItem.getId())) .andExpect(status().isOk()).andReturn())) .contains("子站事项").contains("子站流程").contains("rel=\"canonical\""); } @Test @DisplayName("指南页浏览量异步累加:HTTP 访问触发 + 计数器原子自增(不承诺即时,轮询等待)") void guideItem_viewCountIncrementsAsync() throws Exception { Integer id = publishItem("S1", "转学办理", "person", "jw"); // ① HTTP 路径:指南页渲染成功并触发一次计数(异步) mockMvc.perform(get("/portal/demo1/sv/{id}", id)).andExpect(status().isOk()); // ② 再补 4 次显式计数:把"异步累加链路是否工作"与"MockMvc 渲染耗时"解耦 for (int i = 0; i < 4; i++) { svcItemQuery.countView(id); } int expected = 5; int viewCount = 0; // 轮询预算 30s:异步任务是 fire-and-forget(设计不承诺即时),且满载环境下取连接可能等待 // Hikari 默认 connectionTimeout(30s),故预算需覆盖该量级,避免把连接竞争误判为功能缺陷 for (int i = 0; i < 300; i++) { viewCount = serviceItemRepository.findById(id).orElseThrow(IllegalStateException::new) .getViewCount(); if (viewCount >= expected) { break; } java.lang.Thread.sleep(100); } assertThat(viewCount).as("1 次指南页访问 + 4 次显式计数应累加为 %s", expected) .isGreaterThanOrEqualTo(expected); } // ===== 指南页装配(§5.3)===== @Test @DisplayName("指南页:富文本补上传基准、附件下载、关联栏目 url.category、关联事项、手填外链按钮") void guide_modelAssembly() throws Exception { Integer faqCat = persistCategory(siteId, 0, "FAQ", com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId(); com.yunhai.cms.svcitem.entity.CmsServiceItem item = serviceItemRepository.findById(publishItem("S1", "转学办理", "person", "jw")).orElseThrow(IllegalStateException::new); item.setProcessHtml("

"); item.setFaqCategoryId(faqCat); item.setExtJson("{\"apply_url\":\"https://apply.example.com/zx\"}"); serviceItemRepository.saveAndFlush(item); com.yunhai.cms.entity.CmsAttachment att = persistAttachment(siteId, "svc_item", "申请表.pdf"); att.setRefId(item.getId()); attachmentRepository.saveAndFlush(att); publishItem("S2", "休学办理", "person", "jw"); // 同类别关联事项 String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", item.getId())) .andExpect(status().isOk()).andReturn()); // 上传地址渲染期补基准(B-1 口径:MockMvc 请求派生基准 = http://localhost) assertThat(html).contains("src=\"http://localhost/cms/upload/cms/demo1/202609/a.jpg\""); // 附件与下载链接(webUrl 相对路径经 url.upload 补基准) assertThat(html).contains("申请表.pdf"); assertThat(html).contains("http://localhost/cms/upload/cms/" + siteId + "/"); // 关联栏目链接经 url.category 生成 assertThat(html).contains("/portal/demo1/c/" + faqCat); // 关联事项(同类别已发布,排除自身) assertThat(html).contains("休学办理"); // 我要办理三态①:手填外链优先 assertThat(html).contains("https://apply.example.com/zx"); assertThat(html).contains("我要办理"); } @Test @DisplayName("我要办理三态:② svc_hall_base 生成 svhall 发起页 URL;③ 都没有 → 不渲染按钮") void applyUrl_generatedAndAbsentStates() throws Exception { // ② 未手填且站点配 svc_hall_base → 生成(尾斜杠归一) com.yunhai.cms.entity.CmsSite site = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new); site.setConfigJson("{\"svc_hall_base\":\"https://svhall.example.com/\"}"); siteRepository.saveAndFlush(site); clearCaches(); Integer id = publishItem("S1", "转学办理", "person", "jw"); String html = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id)) .andExpect(status().isOk()).andReturn()); assertThat(html).contains("我要办理"); assertThat(html).contains("https://svhall.example.com/#/apply?siteCode=demo1&itemCode=S1"); // ③ 都没有 → 按钮不渲染 com.yunhai.cms.entity.CmsSite bare = siteRepository.findById(siteId).orElseThrow(IllegalStateException::new); bare.setConfigJson(null); siteRepository.saveAndFlush(bare); clearCaches(); Integer id2 = publishItem("S2", "休学办理", "person", "jw"); String html2 = body(mockMvc.perform(get("/portal/demo1/sv/{id}", id2)) .andExpect(status().isOk()).andReturn()); // 以按钮类名断言(模板源码注释含"我要办理"字样会随注释输出) assertThat(html2).doesNotContain("svc-apply-btn"); assertThat(html2).doesNotContain("svhall.example.com"); } }