feat: make gb32960 archive history query production ready
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public final class RedisVehicleStateRepository implements VehicleStateRepository {
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
|
||||
public RedisVehicleStateRepository(StringRedisTemplate redis) {
|
||||
if (redis == null) {
|
||||
throw new IllegalArgumentException("redis must not be null");
|
||||
}
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putState(String vin, String json) {
|
||||
put(key("vehicle:state:", vin), json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putLocation(String vin, String json) {
|
||||
put(key("vehicle:location:", vin), json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putSafety(String vin, String json) {
|
||||
put(key("vehicle:safety:", vin), json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putLastEvent(String vin, String json) {
|
||||
put(key("vehicle:event:last:", vin), json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getState(String vin) {
|
||||
return get(key("vehicle:state:", vin));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getLocation(String vin) {
|
||||
return get(key("vehicle:location:", vin));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getSafety(String vin) {
|
||||
return get(key("vehicle:safety:", vin));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getLastEvent(String vin) {
|
||||
return get(key("vehicle:event:last:", vin));
|
||||
}
|
||||
|
||||
private void put(String key, String json) {
|
||||
redis.opsForValue().set(key, json == null ? "{}" : json);
|
||||
}
|
||||
|
||||
private Optional<String> get(String key) {
|
||||
return Optional.ofNullable(redis.opsForValue().get(key));
|
||||
}
|
||||
|
||||
private static String key(String prefix, String vin) {
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new IllegalArgumentException("vin must not be blank");
|
||||
}
|
||||
return prefix + vin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-state", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnBean(VehicleStateRepository.class)
|
||||
@RequestMapping(path = "/api/vehicle-state", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public final class VehicleStateController {
|
||||
|
||||
private final VehicleStateRepository repository;
|
||||
|
||||
public VehicleStateController(VehicleStateRepository repository) {
|
||||
if (repository == null) {
|
||||
throw new IllegalArgumentException("repository must not be null");
|
||||
}
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@GetMapping("/{vin}")
|
||||
public ResponseEntity<String> state(@PathVariable String vin) {
|
||||
return json(repository.getState(vin));
|
||||
}
|
||||
|
||||
@GetMapping("/{vin}/location")
|
||||
public ResponseEntity<String> location(@PathVariable String vin) {
|
||||
return json(repository.getLocation(vin));
|
||||
}
|
||||
|
||||
@GetMapping("/{vin}/safety")
|
||||
public ResponseEntity<String> safety(@PathVariable String vin) {
|
||||
return json(repository.getSafety(vin));
|
||||
}
|
||||
|
||||
@GetMapping("/{vin}/last-event")
|
||||
public ResponseEntity<String> lastEvent(@PathVariable String vin) {
|
||||
return json(repository.getLastEvent(vin));
|
||||
}
|
||||
|
||||
private static ResponseEntity<String> json(Optional<String> value) {
|
||||
return value.map(json -> ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(json))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestResult;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestor;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
|
||||
public final class VehicleStateEnvelopeIngestor implements EnvelopeIngestor {
|
||||
|
||||
private final VehicleStateUpdater updater;
|
||||
|
||||
public VehicleStateEnvelopeIngestor(VehicleStateUpdater updater) {
|
||||
if (updater == null) {
|
||||
throw new IllegalArgumentException("updater must not be null");
|
||||
}
|
||||
this.updater = updater;
|
||||
}
|
||||
|
||||
public void ingest(byte[] kafkaValue) {
|
||||
updater.update(parse(kafkaValue));
|
||||
}
|
||||
|
||||
@Override
|
||||
public EnvelopeIngestResult tryIngest(byte[] kafkaValue) {
|
||||
VehicleEnvelope envelope = null;
|
||||
try {
|
||||
envelope = parse(kafkaValue);
|
||||
updater.update(envelope);
|
||||
return EnvelopeIngestResult.processed(envelope.getEventId(), envelope.getVin());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return envelope == null
|
||||
? EnvelopeIngestResult.invalid(ex.getMessage())
|
||||
: EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static VehicleEnvelope parse(byte[] kafkaValue) {
|
||||
if (kafkaValue == null || kafkaValue.length == 0) {
|
||||
throw new IllegalArgumentException("VehicleEnvelope bytes must not be empty");
|
||||
}
|
||||
try {
|
||||
return VehicleEnvelope.parseFrom(kafkaValue);
|
||||
} catch (InvalidProtocolBufferException ex) {
|
||||
throw new IllegalArgumentException("VehicleEnvelope bytes are invalid", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface VehicleStateRepository {
|
||||
|
||||
void putState(String vin, String json);
|
||||
|
||||
void putLocation(String vin, String json);
|
||||
|
||||
void putSafety(String vin, String json);
|
||||
|
||||
void putLastEvent(String vin, String json);
|
||||
|
||||
Optional<String> getState(String vin);
|
||||
|
||||
Optional<String> getLocation(String vin);
|
||||
|
||||
Optional<String> getSafety(String vin);
|
||||
|
||||
Optional<String> getLastEvent(String vin);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class VehicleStateUpdater {
|
||||
|
||||
private final VehicleStateRepository repository;
|
||||
|
||||
public VehicleStateUpdater(VehicleStateRepository repository) {
|
||||
if (repository == null) {
|
||||
throw new IllegalArgumentException("repository must not be null");
|
||||
}
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public void update(VehicleEnvelope envelope) {
|
||||
if (envelope == null) {
|
||||
throw new IllegalArgumentException("envelope must not be null");
|
||||
}
|
||||
if (!envelope.hasTelemetrySnapshot()) {
|
||||
throw new IllegalArgumentException("envelope telemetry_snapshot is required");
|
||||
}
|
||||
Map<String, String> fields = fields(envelope);
|
||||
String vin = envelope.getVin();
|
||||
|
||||
repository.putState(vin, json(base(envelope, fields)));
|
||||
repository.putLastEvent(vin, json(lastEvent(envelope)));
|
||||
|
||||
if (fields.containsKey("longitude") && fields.containsKey("latitude")) {
|
||||
repository.putLocation(vin, json(location(envelope, fields)));
|
||||
}
|
||||
if (hasSafetyFields(fields)) {
|
||||
repository.putSafety(vin, json(safety(envelope, fields)));
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> fields(VehicleEnvelope envelope) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
for (TelemetryField field : envelope.getTelemetrySnapshot().getFieldsList()) {
|
||||
fields.put(field.getKey(), field.getValue());
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static Map<String, Object> base(VehicleEnvelope envelope, Map<String, String> fields) {
|
||||
Map<String, Object> map = eventIdentity(envelope);
|
||||
map.put("eventType", envelope.getTelemetrySnapshot().getEventType());
|
||||
map.put("fields", fields);
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<String, Object> location(VehicleEnvelope envelope, Map<String, String> fields) {
|
||||
Map<String, Object> map = eventIdentity(envelope);
|
||||
putIfPresent(map, fields, "longitude");
|
||||
putIfPresent(map, fields, "latitude");
|
||||
putIfPresent(map, fields, "altitude_m");
|
||||
putIfPresent(map, fields, "direction_deg");
|
||||
putIfPresent(map, fields, "speed_kmh");
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<String, Object> safety(VehicleEnvelope envelope, Map<String, String> fields) {
|
||||
Map<String, Object> map = eventIdentity(envelope);
|
||||
putIfPresent(map, fields, "safety_category");
|
||||
putIfPresent(map, fields, "hydrogen_leak_detected");
|
||||
putIfPresent(map, fields, "hydrogen_leak_level");
|
||||
putIfPresent(map, fields, "hydrogen_leak_action_required");
|
||||
putIfPresent(map, fields, "alarm_level");
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<String, Object> lastEvent(VehicleEnvelope envelope) {
|
||||
return eventIdentity(envelope);
|
||||
}
|
||||
|
||||
private static Map<String, Object> eventIdentity(VehicleEnvelope envelope) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("eventId", envelope.getEventId());
|
||||
map.put("traceId", envelope.getTraceId());
|
||||
map.put("vin", envelope.getVin());
|
||||
map.put("source", envelope.getSource());
|
||||
map.put("eventTimeMs", Long.toString(envelope.getEventTimeMs()));
|
||||
map.put("ingestTimeMs", Long.toString(envelope.getIngestTimeMs()));
|
||||
return map;
|
||||
}
|
||||
|
||||
private static boolean hasSafetyFields(Map<String, String> fields) {
|
||||
return fields.containsKey("safety_category")
|
||||
|| fields.containsKey("hydrogen_leak_detected")
|
||||
|| fields.containsKey("hydrogen_leak_level")
|
||||
|| fields.containsKey("hydrogen_leak_action_required")
|
||||
|| fields.containsKey("alarm_level");
|
||||
}
|
||||
|
||||
private static void putIfPresent(Map<String, Object> map, Map<String, String> fields, String key) {
|
||||
if (fields.containsKey(key)) {
|
||||
map.put(key, fields.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
private static String json(Map<String, ?> map) {
|
||||
StringBuilder out = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, ?> entry : map.entrySet()) {
|
||||
if (!first) {
|
||||
out.append(',');
|
||||
}
|
||||
first = false;
|
||||
out.append('"').append(escape(entry.getKey())).append('"').append(':');
|
||||
Object value = entry.getValue();
|
||||
if (value instanceof Map<?, ?> nested) {
|
||||
out.append(jsonObject(nested));
|
||||
} else {
|
||||
out.append('"').append(escape(String.valueOf(value))).append('"');
|
||||
}
|
||||
}
|
||||
return out.append('}').toString();
|
||||
}
|
||||
|
||||
private static String jsonObject(Map<?, ?> map) {
|
||||
StringBuilder out = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (!first) {
|
||||
out.append(',');
|
||||
}
|
||||
first = false;
|
||||
out.append('"').append(escape(String.valueOf(entry.getKey()))).append('"')
|
||||
.append(':')
|
||||
.append('"').append(escape(String.valueOf(entry.getValue()))).append('"');
|
||||
}
|
||||
return out.append('}').toString();
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lingniu.ingest.vehiclestate.config;
|
||||
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink;
|
||||
import com.lingniu.ingest.vehiclestate.RedisVehicleStateRepository;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateController;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateEnvelopeIngestor;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateRepository;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateUpdater;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-state", name = "enabled", havingValue = "true")
|
||||
public class VehicleStateAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(StringRedisTemplate.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStateRepository vehicleStateRepository(StringRedisTemplate redis) {
|
||||
return new RedisVehicleStateRepository(redis);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStateRepository.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStateUpdater vehicleStateUpdater(VehicleStateRepository repository) {
|
||||
return new VehicleStateUpdater(repository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStateUpdater.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStateEnvelopeIngestor vehicleStateEnvelopeIngestor(VehicleStateUpdater updater) {
|
||||
return new VehicleStateEnvelopeIngestor(updater);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean({VehicleStateEnvelopeIngestor.class, EnvelopeDeadLetterSink.class})
|
||||
@ConditionalOnMissingBean(name = "vehicleStateEnvelopeConsumerProcessor")
|
||||
public EnvelopeConsumerProcessor vehicleStateEnvelopeConsumerProcessor(VehicleStateEnvelopeIngestor ingestor,
|
||||
EnvelopeDeadLetterSink deadLetterSink) {
|
||||
return new EnvelopeConsumerProcessor("vehicle-state", ingestor, deadLetterSink);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStateRepository.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStateController vehicleStateController(VehicleStateRepository repository) {
|
||||
return new VehicleStateController(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.vehiclestate.config.VehicleStateAutoConfiguration
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
final class InMemoryVehicleStateRepository implements VehicleStateRepository {
|
||||
String state;
|
||||
String location;
|
||||
String safety;
|
||||
String lastEvent;
|
||||
|
||||
@Override
|
||||
public void putState(String vin, String json) {
|
||||
this.state = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putLocation(String vin, String json) {
|
||||
this.location = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putSafety(String vin, String json) {
|
||||
this.safety = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putLastEvent(String vin, String json) {
|
||||
this.lastEvent = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getState(String vin) {
|
||||
return Optional.ofNullable(state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getLocation(String vin) {
|
||||
return Optional.ofNullable(location);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getSafety(String vin) {
|
||||
return Optional.ofNullable(safety);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getLastEvent(String vin) {
|
||||
return Optional.ofNullable(lastEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RedisVehicleStateRepositoryTest {
|
||||
|
||||
@Test
|
||||
void writesAndReadsHotStateJsonByStableRedisKey() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> ops = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(ops);
|
||||
when(ops.get("vehicle:state:VIN001")).thenReturn("{\"vin\":\"VIN001\"}");
|
||||
|
||||
RedisVehicleStateRepository repository = new RedisVehicleStateRepository(redis);
|
||||
|
||||
repository.putState("VIN001", "{\"vin\":\"VIN001\"}");
|
||||
Optional<String> value = repository.getState("VIN001");
|
||||
|
||||
verify(ops).set("vehicle:state:VIN001", "{\"vin\":\"VIN001\"}");
|
||||
assertThat(value).contains("{\"vin\":\"VIN001\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesLocationSafetyAndLastEventToSeparateKeys() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> ops = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(ops);
|
||||
|
||||
RedisVehicleStateRepository repository = new RedisVehicleStateRepository(redis);
|
||||
|
||||
repository.putLocation("VIN001", "{\"longitude\":113.12}");
|
||||
repository.putSafety("VIN001", "{\"hydrogen_leak_detected\":true}");
|
||||
repository.putLastEvent("VIN001", "{\"eventId\":\"event-1\"}");
|
||||
|
||||
verify(ops).set("vehicle:location:VIN001", "{\"longitude\":113.12}");
|
||||
verify(ops).set("vehicle:safety:VIN001", "{\"hydrogen_leak_detected\":true}");
|
||||
verify(ops).set("vehicle:event:last:VIN001", "{\"eventId\":\"event-1\"}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
|
||||
|
||||
class VehicleStateControllerTest {
|
||||
|
||||
@Test
|
||||
void returnsLatestStateJson() throws Exception {
|
||||
VehicleStateRepository repository = mock(VehicleStateRepository.class);
|
||||
when(repository.getState("VIN001")).thenReturn(java.util.Optional.of("{\"speed_kmh\":\"80.5\"}"));
|
||||
MockMvc mvc = standaloneSetup(new VehicleStateController(repository)).build();
|
||||
|
||||
mvc.perform(get("/api/vehicle-state/VIN001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{\"speed_kmh\":\"80.5\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsLocationSafetyAndLastEventJson() throws Exception {
|
||||
VehicleStateRepository repository = mock(VehicleStateRepository.class);
|
||||
when(repository.getLocation("VIN001")).thenReturn(java.util.Optional.of("{\"longitude\":\"113.12\"}"));
|
||||
when(repository.getSafety("VIN001")).thenReturn(java.util.Optional.of("{\"hydrogen_leak_detected\":\"true\"}"));
|
||||
when(repository.getLastEvent("VIN001")).thenReturn(java.util.Optional.of("{\"eventId\":\"event-1\"}"));
|
||||
MockMvc mvc = standaloneSetup(new VehicleStateController(repository)).build();
|
||||
|
||||
mvc.perform(get("/api/vehicle-state/VIN001/location"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{\"longitude\":\"113.12\"}"));
|
||||
mvc.perform(get("/api/vehicle-state/VIN001/safety"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{\"hydrogen_leak_detected\":\"true\"}"));
|
||||
mvc.perform(get("/api/vehicle-state/VIN001/last-event"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{\"eventId\":\"event-1\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNotFoundWhenStateIsMissing() throws Exception {
|
||||
VehicleStateRepository repository = mock(VehicleStateRepository.class);
|
||||
when(repository.getState("VIN001")).thenReturn(java.util.Optional.empty());
|
||||
MockMvc mvc = standaloneSetup(new VehicleStateController(repository)).build();
|
||||
|
||||
mvc.perform(get("/api/vehicle-state/VIN001"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestResult;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestor;
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class VehicleStateEnvelopeIngestorTest {
|
||||
|
||||
@Test
|
||||
void parsesEnvelopeBytesAndUpdatesRepository() {
|
||||
InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository();
|
||||
VehicleStateEnvelopeIngestor ingestor = new VehicleStateEnvelopeIngestor(new VehicleStateUpdater(repository));
|
||||
|
||||
assertThat(ingestor).isInstanceOf(EnvelopeIngestor.class);
|
||||
ingestor.ingest(envelope().toByteArray());
|
||||
|
||||
assertThat(repository.getState("VIN001")).hasValueSatisfying(json -> assertThat(json).contains("speed_kmh"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidEnvelopeBytes() {
|
||||
VehicleStateEnvelopeIngestor ingestor =
|
||||
new VehicleStateEnvelopeIngestor(new VehicleStateUpdater(new EmptyRepository()));
|
||||
|
||||
assertThatThrownBy(() -> ingestor.ingest(new byte[]{0x01, 0x02}))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("VehicleEnvelope");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryIngestInvalidEnvelopeBytesReturnsInvalidWithoutUpdatingRepository() {
|
||||
InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository();
|
||||
VehicleStateEnvelopeIngestor ingestor = new VehicleStateEnvelopeIngestor(new VehicleStateUpdater(repository));
|
||||
|
||||
var result = ingestor.tryIngest(new byte[]{0x01, 0x02});
|
||||
|
||||
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE);
|
||||
assertThat(result.message()).contains("VehicleEnvelope");
|
||||
assertThat(repository.getState("VIN001")).isEmpty();
|
||||
}
|
||||
|
||||
private static VehicleEnvelope envelope() {
|
||||
return VehicleEnvelope.newBuilder()
|
||||
.setEventId("event-1")
|
||||
.setVin("VIN001")
|
||||
.setSource("GB32960")
|
||||
.setEventTimeMs(1)
|
||||
.setIngestTimeMs(2)
|
||||
.setTelemetrySnapshot(TelemetrySnapshot.newBuilder()
|
||||
.setEventType("REALTIME")
|
||||
.addFields(TelemetryField.newBuilder()
|
||||
.setKey("speed_kmh")
|
||||
.setValueType("DOUBLE")
|
||||
.setValue("80.5")
|
||||
.setUnit("km/h")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final class EmptyRepository implements VehicleStateRepository {
|
||||
@Override public void putState(String vin, String json) {}
|
||||
@Override public void putLocation(String vin, String json) {}
|
||||
@Override public void putSafety(String vin, String json) {}
|
||||
@Override public void putLastEvent(String vin, String json) {}
|
||||
@Override public Optional<String> getState(String vin) { return Optional.empty(); }
|
||||
@Override public Optional<String> getLocation(String vin) { return Optional.empty(); }
|
||||
@Override public Optional<String> getSafety(String vin) { return Optional.empty(); }
|
||||
@Override public Optional<String> getLastEvent(String vin) { return Optional.empty(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class VehicleStateUpdaterTest {
|
||||
|
||||
@Test
|
||||
void writesStateLocationSafetyAndLastEventFromTelemetrySnapshot() {
|
||||
InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository();
|
||||
VehicleStateUpdater updater = new VehicleStateUpdater(repository);
|
||||
|
||||
updater.update(envelope()
|
||||
.addField("speed_kmh", "DOUBLE", "80.5", "km/h")
|
||||
.addField("longitude", "DOUBLE", "113.12", "deg")
|
||||
.addField("latitude", "DOUBLE", "23.45", "deg")
|
||||
.addField("hydrogen_leak_detected", "BOOLEAN", "true", "")
|
||||
.addField("hydrogen_leak_level", "STRING", "CRITICAL", "")
|
||||
.build());
|
||||
|
||||
assertThat(repository.state).contains("\"vin\":\"VIN001\"");
|
||||
assertThat(repository.state).contains("\"speed_kmh\"");
|
||||
assertThat(repository.location).contains("\"longitude\":\"113.12\"");
|
||||
assertThat(repository.location).contains("\"latitude\":\"23.45\"");
|
||||
assertThat(repository.safety).contains("\"hydrogen_leak_detected\":\"true\"");
|
||||
assertThat(repository.lastEvent).contains("\"eventId\":\"event-1\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotOverwriteLocationOrSafetyWhenFieldsAreMissing() {
|
||||
InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository();
|
||||
repository.location = "{\"longitude\":\"old\"}";
|
||||
repository.safety = "{\"hydrogen_leak_detected\":\"old\"}";
|
||||
|
||||
new VehicleStateUpdater(repository).update(envelope()
|
||||
.addField("speed_kmh", "DOUBLE", "80.5", "km/h")
|
||||
.build());
|
||||
|
||||
assertThat(repository.location).isEqualTo("{\"longitude\":\"old\"}");
|
||||
assertThat(repository.safety).isEqualTo("{\"hydrogen_leak_detected\":\"old\"}");
|
||||
assertThat(repository.state).contains("\"speed_kmh\"");
|
||||
}
|
||||
|
||||
private static EnvelopeBuilder envelope() {
|
||||
return new EnvelopeBuilder();
|
||||
}
|
||||
|
||||
private static final class EnvelopeBuilder {
|
||||
private final TelemetrySnapshot.Builder snapshot = TelemetrySnapshot.newBuilder()
|
||||
.setEventType("REALTIME")
|
||||
.setRawArchiveUri("archive://raw/event-1.bin");
|
||||
|
||||
EnvelopeBuilder addField(String key, String valueType, String value, String unit) {
|
||||
snapshot.addFields(TelemetryField.newBuilder()
|
||||
.setKey(key)
|
||||
.setValueType(valueType)
|
||||
.setValue(value)
|
||||
.setUnit(unit)
|
||||
.setQuality("GOOD")
|
||||
.setSourcePath("test"));
|
||||
return this;
|
||||
}
|
||||
|
||||
VehicleEnvelope build() {
|
||||
return VehicleEnvelope.newBuilder()
|
||||
.setEventId("event-1")
|
||||
.setTraceId("trace-1")
|
||||
.setVin("VIN001")
|
||||
.setSource("GB32960")
|
||||
.setEventTimeMs(1_782_112_400_000L)
|
||||
.setIngestTimeMs(1_782_112_401_000L)
|
||||
.setTelemetrySnapshot(snapshot)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lingniu.ingest.vehiclestate.config;
|
||||
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink;
|
||||
import com.lingniu.ingest.vehiclestate.RedisVehicleStateRepository;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateController;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateEnvelopeIngestor;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateRepository;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateUpdater;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class VehicleStateAutoConfigurationTest {
|
||||
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(VehicleStateAutoConfiguration.class))
|
||||
.withBean(EnvelopeDeadLetterSink.class, () -> record -> {})
|
||||
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class));
|
||||
|
||||
@Test
|
||||
void createsVehicleStateBeansWhenEnabled() {
|
||||
contextRunner
|
||||
.withPropertyValues("lingniu.ingest.vehicle-state.enabled=true")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(VehicleStateRepository.class);
|
||||
assertThat(context).hasSingleBean(RedisVehicleStateRepository.class);
|
||||
assertThat(context).hasSingleBean(VehicleStateUpdater.class);
|
||||
assertThat(context).hasSingleBean(VehicleStateEnvelopeIngestor.class);
|
||||
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);
|
||||
assertThat(context).hasSingleBean(VehicleStateController.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void backsOffWhenDisabled() {
|
||||
contextRunner.run(context -> {
|
||||
assertThat(context).doesNotHaveBean(VehicleStateRepository.class);
|
||||
assertThat(context).doesNotHaveBean(VehicleStateUpdater.class);
|
||||
assertThat(context).doesNotHaveBean(VehicleStateEnvelopeIngestor.class);
|
||||
assertThat(context).doesNotHaveBean(VehicleStateController.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotCreateRepositoryWithoutRedisTemplate() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(VehicleStateAutoConfiguration.class))
|
||||
.withPropertyValues("lingniu.ingest.vehicle-state.enabled=true")
|
||||
.run(context -> assertThat(context).doesNotHaveBean(VehicleStateRepository.class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user