|
|
@@ -0,0 +1,243 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.etotem.cfc.entity.HealthReport;
|
|
|
+import com.etotem.cfc.entity.IndicatorValue;
|
|
|
+import com.etotem.cfc.mapper.HealthReportMapper;
|
|
|
+import com.etotem.cfc.mapper.IndicatorValueMapper;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.sql.Date;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyCollection;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 存量时序回填(backfillIndicatorValues)健壮性单测(T04 第二段 gate 取证,修复 DS-2026-10-25-01 §4.6 缺陷)。
|
|
|
+ *
|
|
|
+ * <p>覆盖缺陷(旧实现 L1716 selectCount 在 per-report try/catch 之外 + covered=count>0):
|
|
|
+ * <ul>
|
|
|
+ * <li>#A 覆盖判定查询抛异常不中断整批:后续报告仍被处理,而非整批回填中止;</li>
|
|
|
+ * <li>#B 部分写入的报告(如中断后仅写入 13/33 行)不会被永久跳过,重跑会补齐;</li>
|
|
|
+ * <li>#C 已完全覆盖(exact 命中且所有 def 已写)的报告仍被跳过(幂等,不重复写);</li>
|
|
|
+ * <li>#D fuzzy 坍缩(部分名称仅能 fuzzy 命中,无 exact 规则)不被误判为已覆盖,重跑会补齐缺失名称;
|
|
|
+ * 与 r28 实测同构(99 名称仅写 68 def,fuzzy 会把部分写入误判为完整,故按 exact 口径判定);</li>
|
|
|
+ * <li>#E dryRun 无副作用。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+class HealthReportServiceBackfillResilienceTest {
|
|
|
+
|
|
|
+ private HealthReportService service;
|
|
|
+ private HealthReportMapper healthReportMapper;
|
|
|
+ private IndicatorValueMapper valueMapper;
|
|
|
+ private IndicatorMappingService mappingService;
|
|
|
+ private NormalizeIndicatorPipeline pipeline;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
|
|
+ HealthReport.class);
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
|
|
+ IndicatorValue.class);
|
|
|
+
|
|
|
+ service = new HealthReportService();
|
|
|
+ healthReportMapper = mock(HealthReportMapper.class);
|
|
|
+ valueMapper = mock(IndicatorValueMapper.class);
|
|
|
+ mappingService = mock(IndicatorMappingService.class);
|
|
|
+ pipeline = mock(NormalizeIndicatorPipeline.class);
|
|
|
+
|
|
|
+ ReflectionTestUtils.setField(service, "healthReportMapper", healthReportMapper);
|
|
|
+ ReflectionTestUtils.setField(service, "indicatorValueMapper", valueMapper);
|
|
|
+ ReflectionTestUtils.setField(service, "mappingService", mappingService);
|
|
|
+ ReflectionTestUtils.setField(service, "normalizeIndicatorPipeline", pipeline);
|
|
|
+ ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
|
|
+
|
|
|
+ ReportDateExtractor extractor = mock(ReportDateExtractor.class);
|
|
|
+ when(extractor.extractReportDate(any())).thenReturn(null); // 不走日期修复分支
|
|
|
+ ReflectionTestUtils.setField(service, "reportDateExtractor", extractor);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 工具 ----------
|
|
|
+
|
|
|
+ private HealthReport report(long id, String payloadJson) {
|
|
|
+ HealthReport r = new HealthReport();
|
|
|
+ r.setId(id);
|
|
|
+ r.setSubjectId(88L);
|
|
|
+ r.setReportType("gut_flora");
|
|
|
+ r.setReportDate(Date.valueOf("2026-08-28"));
|
|
|
+ r.setStatus("active");
|
|
|
+ r.setCreatedAt(new java.util.Date());
|
|
|
+ r.setPayloadJson(payloadJson);
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 构造 ResolvedMapping:rawName → defId */
|
|
|
+ private IndicatorMappingService.ResolvedMapping resolved(String rawName, Long defId) {
|
|
|
+ IndicatorMappingService.ResolvedMapping rm = new IndicatorMappingService.ResolvedMapping();
|
|
|
+ rm.setRawName(rawName);
|
|
|
+ rm.setDefinitionId(defId);
|
|
|
+ rm.setDefId(defId);
|
|
|
+ rm.setDefName(rawName);
|
|
|
+ rm.setRuleStatus(1);
|
|
|
+ rm.setPriority(0);
|
|
|
+ return rm;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 构造 resolveExactBatch 结果(exact 命中) */
|
|
|
+ private Map<String, IndicatorMappingService.ResolvedMapping> exactMap(Object... pairs) {
|
|
|
+ Map<String, IndicatorMappingService.ResolvedMapping> m = new LinkedHashMap<>();
|
|
|
+ for (int i = 0; i < pairs.length; i += 2) {
|
|
|
+ m.put((String) pairs[i], resolved((String) pairs[i], (Long) pairs[i + 1]));
|
|
|
+ }
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private IndicatorValue written(Long defId) {
|
|
|
+ IndicatorValue v = new IndicatorValue();
|
|
|
+ v.setDefinitionId(defId);
|
|
|
+ return v;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Map<String, Object>> detailsOf(Map<String, Object> data) {
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ List<Map<String, Object>> details = (List<Map<String, Object>>) data.get("details");
|
|
|
+ return details;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, Object> detailOf(List<Map<String, Object>> details, long reportId) {
|
|
|
+ return details.stream()
|
|
|
+ .filter(d -> ((Number) d.get("reportId")).longValue() == reportId)
|
|
|
+ .findFirst().orElse(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== #A 覆盖判定异常不中断整批 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void backfill_coveredCheckException_doesNotAbortBatch() {
|
|
|
+ String payloadA = "{\"indicators\":[{\"indicatorName\":\"n1\"},{\"indicatorName\":\"n2\"}]}";
|
|
|
+ String payloadB = "{\"indicators\":[{\"indicatorName\":\"m1\"}]}";
|
|
|
+ HealthReport a = report(1L, payloadA);
|
|
|
+ HealthReport b = report(2L, payloadB);
|
|
|
+ when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>(java.util.Arrays.asList(a, b)));
|
|
|
+
|
|
|
+ // 报告 A 的 exact-coverag 检查抛异常(DB 抖动)→ 降级为未覆盖并重试写入;报告 B 正常 exact 命中且已写 → 跳过
|
|
|
+ when(mappingService.resolveExactBatch(anyString(), anyCollection()))
|
|
|
+ .thenThrow(new RuntimeException("connection reset by peer"))
|
|
|
+ .thenReturn(exactMap("m1", 601L));
|
|
|
+ // B 的已写入定义已存在(A 的检查在 selectList 之后抛异常,其 selectList 结果无影响)
|
|
|
+ when(valueMapper.selectList(any()))
|
|
|
+ .thenReturn(new ArrayList<>(java.util.Arrays.asList(written(601L))));
|
|
|
+
|
|
|
+ Map<String, Object> data = service.backfillIndicatorValues(null, null, 50, false, false);
|
|
|
+
|
|
|
+ // 整批未中止:A 被重试写入,B 已覆盖被跳过
|
|
|
+ verify(pipeline).processReport(eq(a), any());
|
|
|
+ verify(pipeline, never()).processReport(eq(b), any());
|
|
|
+ assertEquals(1, ((Number) data.get("written")).intValue());
|
|
|
+ assertEquals(0, ((Number) data.get("failed")).intValue());
|
|
|
+
|
|
|
+ Map<String, Object> dA = detailOf(detailsOf(data), 1L);
|
|
|
+ assertNotNull(dA);
|
|
|
+ assertEquals("written", dA.get("status"));
|
|
|
+ Map<String, Object> dB = detailOf(detailsOf(data), 2L);
|
|
|
+ assertNotNull(dB);
|
|
|
+ assertEquals("already_covered", dB.get("status"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== #B 部分写入不被永久跳过 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void backfill_partiallyCoveredReport_isReprocessed() {
|
|
|
+ String payload = "{\"indicators\":[{\"indicatorName\":\"n1\"},{\"indicatorName\":\"n2\"},{\"indicatorName\":\"n3\"}]}";
|
|
|
+ HealthReport r = report(18L, payload);
|
|
|
+ when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>(java.util.Arrays.asList(r)));
|
|
|
+ when(mappingService.resolveExactBatch(anyString(), anyCollection()))
|
|
|
+ .thenReturn(exactMap("n1", 501L, "n2", 502L, "n3", 503L));
|
|
|
+ // 仅写入 1/3(n1)→ 必须重跑补齐 n2/n3
|
|
|
+ when(valueMapper.selectList(any()))
|
|
|
+ .thenReturn(new ArrayList<>(java.util.Arrays.asList(written(501L))));
|
|
|
+
|
|
|
+ Map<String, Object> data = service.backfillIndicatorValues(null, null, 50, false, false);
|
|
|
+
|
|
|
+ verify(pipeline).processReport(eq(r), any());
|
|
|
+ assertEquals(1, ((Number) data.get("written")).intValue());
|
|
|
+ assertEquals("written", detailOf(detailsOf(data), 18L).get("status"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== #C 已完全覆盖仍跳过 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void backfill_fullyCoveredReport_isSkipped() {
|
|
|
+ String payload = "{\"indicators\":[{\"indicatorName\":\"n1\"},{\"indicatorName\":\"n2\"},{\"indicatorName\":\"n3\"}]}";
|
|
|
+ HealthReport r = report(19L, payload);
|
|
|
+ when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>(java.util.Arrays.asList(r)));
|
|
|
+ when(mappingService.resolveExactBatch(anyString(), anyCollection()))
|
|
|
+ .thenReturn(exactMap("n1", 501L, "n2", 502L, "n3", 503L));
|
|
|
+ // 3/3 全部 exact 写入
|
|
|
+ when(valueMapper.selectList(any()))
|
|
|
+ .thenReturn(new ArrayList<>(java.util.Arrays.asList(written(501L), written(502L), written(503L))));
|
|
|
+
|
|
|
+ Map<String, Object> data = service.backfillIndicatorValues(null, null, 50, false, false);
|
|
|
+
|
|
|
+ verify(pipeline, never()).processReport(any(), any());
|
|
|
+ assertEquals(0, ((Number) data.get("written")).intValue());
|
|
|
+ assertEquals(0, ((Number) data.get("eligible")).intValue());
|
|
|
+ assertEquals("already_covered", detailOf(detailsOf(data), 19L).get("status"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== #D fuzzy 坍缩不被误判为已覆盖 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void backfill_fuzzyCollapsedReport_isReprocessed() {
|
|
|
+ // n1/n2 exact→501,n3 exact→502,n4/n5 仅 fuzzy 命中(无 exact 规则),模拟 r28 99→68
|
|
|
+ String payload = "{\"indicators\":[{\"indicatorName\":\"n1\"},{\"indicatorName\":\"n2\"},"
|
|
|
+ + "{\"indicatorName\":\"n3\"},{\"indicatorName\":\"n4\"},{\"indicatorName\":\"n5\"}]}";
|
|
|
+ HealthReport r = report(20L, payload);
|
|
|
+ when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>(java.util.Arrays.asList(r)));
|
|
|
+ // exact 命中仅 n1/n2/n3;n4/n5 无 exact 规则 → 不出现在结果中
|
|
|
+ when(mappingService.resolveExactBatch(anyString(), anyCollection()))
|
|
|
+ .thenReturn(exactMap("n1", 501L, "n2", 501L, "n3", 502L));
|
|
|
+ // 已写入 501/502/503,但 n4/n5 未 exact 命中 → 视为未覆盖,必须重跑补齐(lazyRegister n4/n5)
|
|
|
+ when(valueMapper.selectList(any()))
|
|
|
+ .thenReturn(new ArrayList<>(java.util.Arrays.asList(written(501L), written(502L), written(503L))));
|
|
|
+
|
|
|
+ Map<String, Object> data = service.backfillIndicatorValues(null, null, 50, false, false);
|
|
|
+
|
|
|
+ verify(pipeline).processReport(eq(r), any());
|
|
|
+ assertEquals(1, ((Number) data.get("written")).intValue());
|
|
|
+ assertEquals("written", detailOf(detailsOf(data), 20L).get("status"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 兜底:dryRun 无副作用 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void backfill_dryRun_writesNothing() {
|
|
|
+ String payload = "{\"indicators\":[{\"indicatorName\":\"n1\"}]}";
|
|
|
+ HealthReport r = report(21L, payload);
|
|
|
+ when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>(java.util.Arrays.asList(r)));
|
|
|
+ when(mappingService.resolveExactBatch(anyString(), anyCollection())).thenReturn(exactMap("n1", 501L));
|
|
|
+ when(valueMapper.selectList(any())).thenReturn(new ArrayList<>());
|
|
|
+
|
|
|
+ Map<String, Object> data = service.backfillIndicatorValues(null, null, 50, true, false);
|
|
|
+
|
|
|
+ verify(pipeline, never()).processReport(any(), any());
|
|
|
+ assertEquals(1, ((Number) data.get("eligible")).intValue());
|
|
|
+ assertEquals("would_write", detailOf(detailsOf(data), 21L).get("status"));
|
|
|
+ assertEquals(true, data.get("dryRun"));
|
|
|
+ }
|
|
|
+}
|