package com.xtrh.console.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.Test; import java.util.concurrent.atomic.AtomicLong; import static com.xtrh.common.util.Jsons.*; import static org.junit.jupiter.api.Assertions.*; class ConsoleCollectionStoreTest { private final AtomicLong time = new AtomicLong(1_000_000); private final ConsoleCollectionStore store = new ConsoleCollectionStore(new ObjectMapper(), time::get); @Test void startsEmptyAndBecomesStaleWithoutReplacingLastRealValues() { assertEquals("WAITING", store.snapshot().path("state").asText()); assertTrue(store.snapshot().path("lastSyncedAt").isNull()); store.sync(input()); assertEquals("CONNECTED", store.snapshot().path("state").asText()); assertEquals(0, firstItem().path("value").asInt()); time.addAndGet(15_001); assertEquals("STALE", store.snapshot().path("state").asText()); assertEquals("speed", firstItem().path("key").asText()); assertEquals(1_000_000, store.snapshot().path("lastSyncedAt").asLong()); ObjectNode fresh = input(); fresh.put("observedAt", time.get()); store.sync(fresh); assertEquals("CONNECTED", store.snapshot().path("state").asText()); } @Test void stripsPrivateFieldsAndFiltersSimulatedAndCredentialItems() { ObjectNode input = input().put("token", "must-not-leak"); ObjectNode equipment = (ObjectNode) input.path("contexts").get(0).path("equipment").get(0); equipment.put("mediaKey", "must-not-leak"); ArrayNode items = (ArrayNode) equipment.path("items"); ((ObjectNode) items.get(0)).put("keyAlias", "must-not-leak"); items.add(item("fake", 50).put("quality", "SIMULATED")); items.add(item("fake2", 50).put("sourceName", "本地模拟")); items.add(item("fake3", 50).put("sourceType", "DEMO")); items.add(item("fake4", 50).put("simulated", true)); items.add(item("mediaKey", 123)); store.sync(input); assertEquals(1, store.snapshot().path("contexts").get(0).path("equipment").get(0).path("items").size()); assertFalse(store.snapshot().toString().contains("must-not-leak")); assertFalse(store.snapshot().toString().contains("mediaKey")); ObjectNode copy = store.snapshot(); ((ArrayNode) copy.path("contexts")).removeAll(); assertEquals(1, store.snapshot().path("contexts").size()); } @Test void invalidOrOutOfOrderPayloadDoesNotEraseAcceptedSnapshot() { store.sync(input()); ObjectNode old = input().put("observedAt", time.get() - 1); assertThrows(IllegalArgumentException.class, () -> store.sync(old)); ObjectNode future = input().put("observedAt", time.get() + 60_001); assertThrows(IllegalArgumentException.class, () -> store.sync(future)); ObjectNode nested = input(); ((ObjectNode) nested.path("contexts").get(0).path("equipment").get(0).path("items").get(0)) .set("value", object().put("secret", "hidden")); assertThrows(IllegalArgumentException.class, () -> store.sync(nested)); assertThrows(IllegalArgumentException.class, () -> store.sync(input().put("source", "OTHER"))); assertThrows(IllegalArgumentException.class, () -> store.sync(input().put("simulated", true))); assertEquals("speed", firstItem().path("key").asText()); } @Test void rejectsExcessiveFieldsCountsAndSizeBeforeReplacingState() { store.sync(input()); ObjectNode longName = input(); ((ObjectNode) longName.path("contexts").get(0)).put("name", "x".repeat(201)); assertThrows(IllegalArgumentException.class, () -> store.sync(longName)); ObjectNode excessive = input(); ArrayNode contexts = (ArrayNode) excessive.path("contexts"); for (int i = 0; i < 32; i++) contexts.add(object().put("id", "context-" + i).set("equipment", array())); assertThrows(IllegalArgumentException.class, () -> store.sync(excessive)); ObjectNode large = input().put("privateExtra", "x".repeat(1_500_000)); assertThrows(IllegalArgumentException.class, () -> store.sync(large)); assertEquals(1, store.snapshot().path("contexts").size()); } @Test void nullUnknownQualityAndMissingSignalRemainUnavailableRatherThanBecomingZero() { ObjectNode input = input(); ObjectNode item = (ObjectNode) input.path("contexts").get(0).path("equipment").get(0).path("items").get(0); item.putNull("value").putNull("receivedAt").put("quality", "UNRECOGNIZED"); store.sync(input); assertTrue(firstItem().path("value").isNull()); assertTrue(firstItem().path("receivedAt").isNull()); assertEquals("UNKNOWN", firstItem().path("quality").asText()); } private ObjectNode input() { ObjectNode result = object().put("source", "PXHJ").put("observedAt", time.get()); ObjectNode equipment = object().put("id", "equipment-a").put("participantCode", "p-1") .put("name", "真实装备").put("code", "E-01").put("state", "ONLINE").put("lastReceivedAt", time.get()); equipment.set("items", array().add(item("speed", 0))); ObjectNode context = object().put("id", "trial-1").put("name", "试验").put("roundId", "round-1"); context.set("equipment", array().add(equipment)); result.set("contexts", array().add(context)); return result; } private ObjectNode item(String key, int value) { return object().put("key", key).put("label", "速度").put("type", "NUMBER").put("value", value) .put("unit", "m/s").put("receivedAt", time.get()).put("sampleTime", time.get()) .put("quality", "GOOD").put("sourceName", "采集模块").put("sourceType", "FIXED_MODULE"); } private com.fasterxml.jackson.databind.JsonNode firstItem() { return store.snapshot().path("contexts").get(0).path("equipment").get(0).path("items").get(0); } }