feat: make gb32960 archive history query production ready

This commit is contained in:
kkfluous
2026-06-23 11:55:44 +08:00
parent b14871ff1c
commit ba68ffe061
462 changed files with 23639 additions and 2341 deletions

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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("\"", "\\\"");
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.vehiclestate.config.VehicleStateAutoConfiguration

View File

@@ -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);
}
}

View File

@@ -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\"}");
}
}

View File

@@ -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());
}
}

View File

@@ -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(); }
}
}

View File

@@ -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();
}
}
}

View File

@@ -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));
}
}