package com.battery.business.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.util.Date; import org.junit.jupiter.api.Test; import com.ruoyi.common.exception.ServiceException; import com.battery.business.domain.TDeviceAdapter; import com.battery.business.domain.TDeviceIngestRecord; import com.battery.business.mapper.TDeviceAdapterMapper; import com.battery.business.service.DeviceAdapterErrorCodes; import com.battery.business.service.DeviceIngestErrorCodes; import org.mockito.Mockito; /** * 推式来源接收端点的判定口径:凭据、来源状态与载荷不合法时**整批不落库**(取数程序因此不推进水位), * 合法批次里厂商原始行原样落地、身份与时间由后端解析、重复推送幂等、同身份不同内容不覆盖。 */ class DeviceIngestServiceTest { private static final String ADAPTER = "j22"; private static final String TOKEN = "j22-token"; /** 现场实测的一行:FILETIME 134349692000000000 等于 2026-09-27 07:53:20 UTC(北京 15:53)。 */ private static final String SAMPLE_BATCH = """ {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[ {"record_time":"134349692000000000","Data_Point":420,"Schedule_Index":2818575540, "Test_Time":600.0,"Step_Time":540.0,"Step_Index":4,"Cycle_Index":1, "Voltage":1.26778,"eCurrent":0.5,"ChargeCapacity":0.12,"DischargeCapacity":0.0, "ChargeEnergy":0.3,"DischargeEnergy":0.0,"IR":0.01,"Power":0.63,"Tempbox":25.4}]} """; private final FakeIngestLanding landing = new FakeIngestLanding(); private final TDeviceAdapterMapper adapters = Mockito.mock(TDeviceAdapterMapper.class); private final Clock clock = Clock.fixed(Instant.parse("2026-09-27T09:00:00Z"), ZoneOffset.UTC); private final DeviceIngestServiceImpl service = new DeviceIngestServiceImpl(adapters, landing, clock, key -> TOKEN); @Test void unknownSourceIsRejectedWithoutWritingAnything() { var failure = assertThrows(ServiceException.class, () -> service.ingest("mystery", TOKEN, "{}")); assertEquals(DeviceAdapterErrorCodes.NOT_FOUND, failure.getCode()); assertTrue(landing.rows().isEmpty()); } @Test void sourceThatIsNotAPushSourceOrIsDisabledIsRejectedWithoutWritingAnything() { Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("NEWARE_MYSQL", "0")); var wrongType = assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH)); assertEquals(DeviceAdapterErrorCodes.UNSUPPORTED_TYPE, wrongType.getCode()); Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "1")); var disabled = assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH)); assertEquals(DeviceIngestErrorCodes.SOURCE_DISABLED, disabled.getCode()); assertTrue(landing.rows().isEmpty()); } /** 凭据不入库:实例配置里只有变量名,取值缺失同样按凭据错误拒绝(失败关闭)。 */ @Test void wrongMissingOrUnresolvableTokenIsRejectedWithoutWritingAnything() { Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); assertEquals(DeviceIngestErrorCodes.TOKEN_INVALID, assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, "wrong", SAMPLE_BATCH)).getCode()); assertEquals(DeviceIngestErrorCodes.TOKEN_INVALID, assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, null, SAMPLE_BATCH)).getCode()); DeviceIngestServiceImpl withoutCredential = new DeviceIngestServiceImpl(adapters, landing, clock, key -> { throw new IllegalStateException("缺少来源凭据:" + key); }); assertEquals(DeviceIngestErrorCodes.TOKEN_INVALID, assertThrows(ServiceException.class, () -> withoutCredential.ingest(ADAPTER, TOKEN, SAMPLE_BATCH)) .getCode()); assertTrue(landing.rows().isEmpty()); } /** 请求体承载厂商原始行:不含已换算的数值,单位换算、时间换算、身份映射与幂等键都在后端完成。 */ @Test void rawVendorRowLandsWithResolvedIdentityTimeAndUnscaledMeasurements() { Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); var result = service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH); assertEquals(1, result.accepted()); TDeviceIngestRecord row = landing.rows().get(0); // 身份映射:22 所没有设备类型与单元维度,一台电脑登记为一个柜,通道号就是厂商通道号。 assertEquals(0, row.getDevtype()); assertEquals(0, row.getDevid()); assertEquals(1, row.getSubdevid()); assertEquals(26, row.getChlid()); // 时间换算:FILETIME → UTC,同时保留设备给出的原始时间文本。 assertEquals(Instant.parse("2026-09-27T07:53:20Z"), row.getDeviceTimeUtc().toInstant()); assertEquals("134349692000000000", row.getDeviceTimeRaw()); // 幂等键:库/表 + Data_Point(22 所每个测试新建一个库,Data_Point 在每个测试内重新编号)。 assertEquals(420L, row.getRecordSequence()); assertEquals("DB_20260927_155209/Ws26_IV_0:420", row.getRecordKey()); assertEquals("DB_20260927_155209/Ws26_IV_0", row.getSourceRef()); // 测量值按物理单位直接落库,不做缩放:1.26778 V 就是 1.26778 V。 assertEquals(0, new java.math.BigDecimal("1.26778").compareTo(row.getVoltageV())); assertEquals(0, new java.math.BigDecimal("0.5").compareTo(row.getCurrentA())); assertEquals(0, new java.math.BigDecimal("0.12").compareTo(row.getChargeCapacityAh())); assertEquals(1, row.getCycleNo()); assertEquals(4, row.getStepNo()); assertEquals(0, new java.math.BigDecimal("540.0").compareTo(row.getRelativeTimeSeconds())); // 原始行的键值原样保留,可供回放与核对。 assertTrue(row.getPayloadJson().contains("Data_Point")); assertTrue(row.getPayloadJson().contains("\"Schedule_Index\":2818575540")); } @Test void repeatedPushIsIdempotentAndConflictingContentIsReportedWithoutOverwriting() { Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); assertEquals(1, service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH).accepted()); // 同一批再推一次:同一条记录被幂等忽略,采样行仍是一行。 var repeat = service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH); assertEquals(0, repeat.accepted()); assertEquals(1, repeat.ignored()); assertEquals(1, landing.samples(ADAPTER, 0, 0, 1, 26).size()); // 同身份但内容不同:报冲突且不覆盖已有事实。 var conflict = service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH.replace("1.26778", "1.99")); assertEquals(1, conflict.conflicts()); assertEquals(0, conflict.accepted()); assertEquals(1, landing.samples(ADAPTER, 0, 0, 1, 26).size()); assertEquals(0, new java.math.BigDecimal("1.26778") .compareTo(landing.samples(ADAPTER, 0, 0, 1, 26).get(0).getVoltageV())); } @Test void topologyHeartbeatAndSamplesShareTheSameLandingTable() { Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); service.ingest(ADAPTER, TOKEN, "{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26},{\"channel\":18}]}"); // 心跳只刷新存活事实,不产生数据行。 var heartbeat = service.ingest(ADAPTER, TOKEN, "{\"kind\":\"HEARTBEAT\"}"); assertEquals(0, heartbeat.accepted()); assertEquals(3, landing.rows().size()); assertEquals(clock.instant(), landing.selectHeartbeatAt(ADAPTER).toInstant()); // 收到采样行同样是"程序还在推"的证据:接受请求就会刷新心跳,不依赖心跳消息本身。 Date before = landing.selectHeartbeatAt(ADAPTER); assertNotNull(before); assertEquals(1, service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH).accepted()); assertEquals(4, landing.rows().size()); assertEquals(before, landing.selectHeartbeatAt(ADAPTER)); // 拓扑上报的通道清单是长期声明,重复上报幂等;采样行按事件逐行落库。 assertEquals(2, landing.selectTopologyChannels(ADAPTER).size()); assertEquals(1, landing.samples(ADAPTER, 0, 0, 1, 26).size()); } @Test void malformedBatchIsRejectedWithTheOffendingRowNamed() { Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); for (String body : new String[] { "{\"kind\":\"SAMPLE\",\"database\":\"DB_x\",\"table\":\"Ws26_IV_0\",\"channel\":26}", "{\"kind\":\"SAMPLE\",\"database\":\"DB_x\",\"table\":\"Ws26_IV_0\",\"rows\":[]}", "{\"kind\":\"SAMPLE\",\"channel\":26,\"rows\":[]}", "{\"kind\":\"SAMPLE\",\"database\":\"DB_x\",\"table\":\"Ws26_IV_0\",\"channel\":26," + "\"rows\":[{\"Voltage\":1.2}]}", "{\"kind\":\"SAMPLE\",\"database\":\"DB_x\",\"table\":\"Ws26_IV_0\",\"channel\":26," + "\"rows\":[{\"Data_Point\":1}]}", "{\"kind\":\"SOMETHING_ELSE\"}", "not-json" }) { var failure = assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, TOKEN, body), body); assertEquals(DeviceIngestErrorCodes.PAYLOAD_INVALID, failure.getCode(), body); } assertTrue(landing.rows().isEmpty()); } private static TDeviceAdapter adapter(String type, String status) { TDeviceAdapter adapter = new TDeviceAdapter(); adapter.setAdapterId(7L); adapter.setAdapterCode(ADAPTER); adapter.setAdapterName("22 所那台"); adapter.setAdapterType(type); adapter.setStatus(status); adapter.setHost("127.0.0.1"); adapter.setPort(8080); adapter.setTimezoneId("Asia/Shanghai"); adapter.setSourceConfig("{\"tokenEnv\":\"BATTERY_INGEST_TOKEN\"}"); return adapter; } }