package gateway import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "errors" "log/slog" "math/big" "os" "path/filepath" "strings" "testing" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics" ) func TestMQTTClientHandleMessagePublishesRawAndFieldsByDefault(t *testing.T) { sink := &recordingSink{} client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "tcp://127.0.0.1:1883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, Sink: sink, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{ "device":"LTEST000000000001", "time":"20260413100000", "data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7} }`)) if len(sink.raw) != 1 || len(sink.fields) != 1 || len(sink.unified) != 0 { t.Fatalf("raw=%d fields=%d unified=%d", len(sink.raw), len(sink.fields), len(sink.unified)) } if sink.raw[0].Protocol != envelope.ProtocolYutongMQTT || sink.raw[0].VIN != "LTEST000000000001" { t.Fatalf("unexpected raw envelope: %#v", sink.raw[0]) } if sink.raw[0].EventKind != envelope.EventKindRaw { t.Fatalf("raw event kind = %q, want %q", sink.raw[0].EventKind, envelope.EventKindRaw) } if sink.raw[0].RawText == "" { t.Fatal("mqtt raw envelope should keep text payload") } if sink.raw[0].RawHex != "" { t.Fatalf("mqtt raw envelope should not duplicate text payload as hex: %q", sink.raw[0].RawHex) } if len(sink.raw[0].Fields) != 0 { t.Fatalf("canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields) } if got, want := sink.fields[0].Fields["yutong_mqtt.data.meter_speed"], sink.raw[0].ParsedFields["yutong_mqtt.data.meter_speed"]; got != want { t.Fatalf("fields event should reuse raw parsed field, got %#v want %#v", got, want) } if sink.fields[0].EventKind != envelope.EventKindFields { t.Fatalf("fields event kind = %q, want %q", sink.fields[0].EventKind, envelope.EventKindFields) } if got, want := sink.fields[0].SourceEventID, sink.raw[0].StableEventID(); got != want { t.Fatalf("fields source event id = %#v, want %s", got, want) } if sink.fields[0].FieldMapping == "" { t.Fatal("fields event should expose field mapping version") } if len(sink.fields[0].Parsed) != 0 || len(sink.fields[0].ParsedFields) != 0 { t.Fatalf("fields envelope should not duplicate parsed payload: parsed=%#v parsed_fields=%#v", sink.fields[0].Parsed, sink.fields[0].ParsedFields) } } func TestMQTTClientDelegatesFieldsProjectionWhenConfigured(t *testing.T) { sink := &recordingSink{} registry := metrics.NewRegistry() client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "tcp://127.0.0.1:1883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, Sink: sink, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), Metrics: registry, DelegateFields: true, }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{ "device":"LTEST000000000001", "time":"20260413100000", "data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7} }`)) if len(sink.raw) != 1 || len(sink.fields) != 0 { t.Fatalf("raw=%d fields=%d, want canonical raw only", len(sink.raw), len(sink.fields)) } if len(sink.raw[0].Fields) != 0 { t.Fatalf("delegated canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields) } text := registry.Render() for _, want := range []string{ `vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="delegated_to_bridge"} 1`, `vehicle_gateway_fields_count{protocol="YUTONG_MQTT",status="delegated_to_bridge"} `, `vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="delegated"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("delegated fields metric missing %s:\n%s", want, text) } } } func TestMQTTClientRecordsMessageMetrics(t *testing.T) { registry := metrics.NewRegistry() client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "tcp://127.0.0.1:1883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, Sink: &recordingSink{}, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), Metrics: registry, }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{ "device":"LTEST000000000001", "time":"20260413100000", "data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7} }`)) text := registry.Render() for _, want := range []string{ `vehicle_gateway_frames_total{protocol="YUTONG_MQTT",status="OK"} 1`, `vehicle_gateway_last_frame_unix_seconds{protocol="YUTONG_MQTT",status="OK"} `, `vehicle_gateway_identity_total{protocol="YUTONG_MQTT",status="resolved"} 1`, `vehicle_gateway_identity_duration_ms{protocol="YUTONG_MQTT",status="resolved"}`, `vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="YUTONG_MQTT",status="resolved"} 1`, `vehicle_gateway_identity_duration_ms_histogram_count{protocol="YUTONG_MQTT",status="resolved"} 1`, `vehicle_gateway_identity_duration_ms_histogram_sum{protocol="YUTONG_MQTT",status="resolved"}`, `vehicle_gateway_publish_total{kind="raw",protocol="YUTONG_MQTT",status="ok"} 1`, `vehicle_gateway_last_publish_unix_seconds{kind="raw",protocol="YUTONG_MQTT",status="ok"} `, `vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="published"} 1`, `vehicle_gateway_fields_count{protocol="YUTONG_MQTT",status="published"} `, `vehicle_gateway_fields_count_histogram_count{protocol="YUTONG_MQTT",status="published"} 1`, `vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="ok"} 1`, `vehicle_gateway_frame_duration_ms{protocol="YUTONG_MQTT",status="OK"}`, `vehicle_gateway_frame_duration_ms_histogram_bucket{le="+Inf",protocol="YUTONG_MQTT",status="OK"} 1`, `vehicle_gateway_frame_duration_ms_histogram_count{protocol="YUTONG_MQTT",status="OK"} 1`, `vehicle_gateway_frame_duration_ms_histogram_sum{protocol="YUTONG_MQTT",status="OK"}`, } { if !strings.Contains(text, want) { t.Fatalf("metrics missing %s:\n%s", want, text) } } if strings.Contains(text, `kind="unified"`) { t.Fatalf("unified publish metric should not be recorded by default:\n%s", text) } } func TestMQTTClientRecordsNonRealtimeFieldsSkipMetric(t *testing.T) { registry := metrics.NewRegistry() client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "tcp://127.0.0.1:1883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, Sink: &recordingSink{}, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), Metrics: registry, }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{ "device":"LTEST000000000001", "time":"20260413100000", "data":{} }`)) text := registry.Render() if !strings.Contains(text, `vehicle_gateway_identity_skips_total{protocol="YUTONG_MQTT",reason="non_vehicle_frame"} 1`) { t.Fatalf("identity skip metric missing:\n%s", text) } if !strings.Contains(text, `vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="skipped_non_realtime"} 1`) { t.Fatalf("non realtime fields skip metric missing:\n%s", text) } } func TestMQTTClientHandleBadPayloadPublishesOnlyRaw(t *testing.T) { sink := &recordingSink{} registry := metrics.NewRegistry() client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "tcp://127.0.0.1:1883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, Sink: sink, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), Metrics: registry, }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } client.handleMessage(context.Background(), "/ytforward/shln/bad", []byte("{bad-json")) if len(sink.raw) != 1 || len(sink.unified) != 0 { t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified)) } if sink.raw[0].ParseStatus != envelope.ParseBadFrame { t.Fatalf("parse status = %q", sink.raw[0].ParseStatus) } if sink.raw[0].RawText == "" { t.Fatal("bad mqtt raw envelope should keep text payload") } if sink.raw[0].RawHex != "" { t.Fatalf("bad mqtt raw envelope should not duplicate text payload as hex: %q", sink.raw[0].RawHex) } if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_parse_errors_total{protocol="YUTONG_MQTT",reason="json"} 1`) { t.Fatalf("parse error metric missing:\n%s", text) } } func TestMQTTClientUsesUncancelledMessageContextForReceivedMessage(t *testing.T) { parent, cancel := context.WithCancel(context.Background()) cancel() resolver := &contextCheckingResolver{} sink := &contextCheckingSink{} client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "tcp://127.0.0.1:1883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, Sink: sink, Resolver: resolver, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } client.handleMessage(parent, "/ytforward/shln/dev1", []byte(`{ "device":"LTEST000000000001", "time":"20260413100000", "data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7} }`)) if resolver.ctxErr != nil { t.Fatalf("resolver saw cancelled context: %v", resolver.ctxErr) } if sink.rawCtxErr != nil { t.Fatalf("raw publish saw cancelled context: %v", sink.rawCtxErr) } if sink.unifiedCtxErr != nil { t.Fatalf("unified publish saw cancelled context: %v", sink.unifiedCtxErr) } if sink.rawCount != 1 || sink.unifiedCount != 0 { t.Fatalf("raw=%d unified=%d", sink.rawCount, sink.unifiedCount) } } func TestMQTTClientPreservesResolvedEnvelopeWhenIdentitySideEffectFails(t *testing.T) { sink := &recordingSink{} registry := metrics.NewRegistry() wantErr := errors.New("registration upsert failed") client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "tcp://127.0.0.1:1883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, Sink: sink, Resolver: resolvedErrorResolver{vin: "LRESOLVED00000001", err: wantErr}, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), Metrics: registry, }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{ "device":"LTEST000000000001", "time":"20260413100000", "data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7} }`)) if len(sink.raw) != 1 { t.Fatalf("raw=%d, want 1", len(sink.raw)) } if sink.raw[0].VIN != "LRESOLVED00000001" { t.Fatalf("raw vin = %q, want resolver vin", sink.raw[0].VIN) } if sink.raw[0].ParseStatus != envelope.ParsePartial { t.Fatalf("parse status = %q, want PARTIAL", sink.raw[0].ParseStatus) } if len(sink.raw[0].Parsed) != 0 { t.Fatalf("canonical raw should not duplicate parsed tree: %#v", sink.raw[0].Parsed) } if got := sink.raw[0].ParsedFields["yutong_mqtt.data.meter_speed"]; got != "52.3" { t.Fatalf("protocol field lost after identity side-effect failure: %#v", got) } for field := range sink.raw[0].ParsedFields { if strings.HasPrefix(field, "yutong_mqtt.identity.") { t.Fatalf("derived identity annotation leaked into protocol fields: %s", field) } } if len(sink.fields) != 1 || sink.fields[0].VIN != "LRESOLVED00000001" { t.Fatalf("fields should preserve resolved vin, fields=%#v", sink.fields) } text := registry.Render() for _, want := range []string{ `vehicle_gateway_identity_total{protocol="YUTONG_MQTT",status="error"} 1`, `vehicle_gateway_identity_issues_total{message_id="MQTT",protocol="YUTONG_MQTT",reason="resolver_error",status="error"} 1`, `vehicle_gateway_frames_total{protocol="YUTONG_MQTT",status="PARTIAL"} 1`, `vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="published"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("metric missing %s:\n%s", want, text) } } } func TestMQTTClientPublishesUnifiedWhenExplicitlyEnabled(t *testing.T) { sink := &recordingSink{} client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "tcp://127.0.0.1:1883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, PublishUnified: true, Sink: sink, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{ "device":"LTEST000000000001", "time":"20260413100000", "data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7} }`)) if len(sink.raw) != 1 || len(sink.unified) != 1 { t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified)) } } func TestMQTTClientPublishesUnifiedWhenFieldsPublishFails(t *testing.T) { sink := &recordingSink{fieldsErr: errors.New("fields queue full")} registry := metrics.NewRegistry() client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "tcp://127.0.0.1:1883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, PublishUnified: true, Sink: sink, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), Metrics: registry, }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{ "device":"LTEST000000000001", "time":"20260413100000", "data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7} }`)) if len(sink.raw) != 1 || len(sink.fields) != 1 || len(sink.unified) != 1 { t.Fatalf("raw=%d fields=%d unified=%d", len(sink.raw), len(sink.fields), len(sink.unified)) } text := registry.Render() for _, want := range []string{ `vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="publish_error"} 1`, `vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="error"} 1`, `vehicle_gateway_publish_total{kind="unified",protocol="YUTONG_MQTT",status="ok"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("metric missing %s:\n%s", want, text) } } } func TestMQTTClientBuildOptionsLoadsTLSCertificates(t *testing.T) { dir := t.TempDir() caPath, certPath, keyPath := writeTestTLSMaterial(t, dir) client, err := NewMQTTClient(MQTTClientConfig{ EndpointName: "endpoint-a", Broker: "ssl://mqtt.example.test:8883", ClientID: "test-client", Topics: []string{"/ytforward/shln/+"}, QoS: 1, Sink: &recordingSink{}, Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), TLSCACertPath: caPath, TLSClientCertPath: certPath, TLSClientKeyPath: keyPath, TLSHostnameVerification: false, CleanSession: true, KeepAlive: 20 * time.Second, ConnectTimeout: 10 * time.Second, }) if err != nil { t.Fatalf("NewMQTTClient() error = %v", err) } opts, err := client.buildOptions(context.Background()) if err != nil { t.Fatalf("buildOptions() error = %v", err) } if opts.TLSConfig == nil { t.Fatal("TLSConfig is nil") } if opts.TLSConfig.RootCAs == nil { t.Fatal("RootCAs is nil") } if len(opts.TLSConfig.Certificates) != 1 { t.Fatalf("client certificates = %d, want 1", len(opts.TLSConfig.Certificates)) } if !opts.TLSConfig.InsecureSkipVerify { t.Fatal("InsecureSkipVerify should be true when hostname verification is disabled") } if !opts.CleanSession { t.Fatal("CleanSession should be true") } if opts.Order { t.Fatal("OrderMatters should be false so MQTT network handling is not blocked by Kafka/DB work") } if !opts.ResumeSubs { t.Fatal("ResumeSubs should be true to avoid subscribe failures during reconnect churn") } if got := opts.KeepAlive; got != 20 { t.Fatalf("KeepAlive = %d, want 20", got) } if got := opts.ConnectTimeout; got != 10*time.Second { t.Fatalf("ConnectTimeout = %v, want 10s", got) } } func writeTestTLSMaterial(t *testing.T, dir string) (string, string, string) { t.Helper() caKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generate ca key: %v", err) } caTemplate := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-ca"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true, IsCA: true, } caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) if err != nil { t.Fatalf("create ca cert: %v", err) } clientKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generate client key: %v", err) } clientTemplate := &x509.Certificate{ SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "test-client"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, } clientDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caTemplate, &clientKey.PublicKey, caKey) if err != nil { t.Fatalf("create client cert: %v", err) } caPath := filepath.Join(dir, "ca.pem") certPath := filepath.Join(dir, "client.pem") keyPath := filepath.Join(dir, "client-key.pem") writePEM(t, caPath, "CERTIFICATE", caDER) writePEM(t, certPath, "CERTIFICATE", clientDER) keyDER := x509.MarshalPKCS1PrivateKey(clientKey) writePEM(t, keyPath, "RSA PRIVATE KEY", keyDER) return caPath, certPath, keyPath } func writePEM(t *testing.T, path, typ string, der []byte) { t.Helper() file, err := os.Create(path) if err != nil { t.Fatalf("create %s: %v", path, err) } defer file.Close() if err := pem.Encode(file, &pem.Block{Type: typ, Bytes: der}); err != nil { t.Fatalf("write pem %s: %v", path, err) } }