package realtime import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) func TestRepositoryUpdatesMergedAndProtocolSnapshots(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() ctx := context.Background() if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, Fields: map[string]any{ envelope.FieldLongitude: 121.1, envelope.FieldTotalMileageKM: 10.5, }, }); err != nil { t.Fatalf("Update() error = %v", err) } if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventTimeMS: 900, ReceivedAtMS: 1200, Fields: map[string]any{ envelope.FieldLongitude: 120.0, envelope.FieldLatitude: 30.5, }, }); err != nil { t.Fatalf("Update() error = %v", err) } merged, err := repo.GetMerged(ctx, "VIN001") if err != nil { t.Fatalf("GetMerged() error = %v", err) } if merged.Fields[envelope.FieldLongitude] != 121.1 { t.Fatalf("older longitude overwrote newer value: %#v", merged.Fields) } if merged.Fields[envelope.FieldLatitude] != 30.5 { t.Fatalf("new latitude missing: %#v", merged.Fields) } if got := protocolNames(merged.Protocols); strings.Join(got, ",") != "GB32960,JT808" { t.Fatalf("merged protocols = %#v", got) } protocol, err := repo.GetProtocol(ctx, "VIN001", envelope.ProtocolJT808) if err != nil { t.Fatalf("GetProtocol() error = %v", err) } if protocol.Fields[envelope.FieldTotalMileageKM] != 10.5 { t.Fatalf("protocol snapshot = %#v", protocol) } } func TestRepositoryStoresFullParsedProtocolSnapshotAndMergesGB32960Units(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() ctx := context.Background() if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, Parsed: map[string]any{ "header": map[string]any{"command": "0x02"}, "data_units": []any{ map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}}, }, }, Fields: map[string]any{envelope.FieldSOCPercent: 88.0}, }); err != nil { t.Fatalf("Update() error = %v", err) } if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventTimeMS: 1100, ReceivedAtMS: 1200, Parsed: map[string]any{ "data_units": []any{ map[string]any{"type": "0x05", "name": "position", "value": map[string]any{"longitude": 121.1, "latitude": 30.2}}, }, }, Fields: map[string]any{envelope.FieldLongitude: 121.1, envelope.FieldLatitude: 30.2}, }); err != nil { t.Fatalf("Update() error = %v", err) } protocol, err := repo.GetProtocol(ctx, "VIN001", envelope.ProtocolGB32960) if err != nil { t.Fatalf("GetProtocol() error = %v", err) } realtimeRaw, err := repo.GetRealtimeRaw(ctx, "VIN001", envelope.ProtocolGB32960) if err != nil { t.Fatalf("GetRealtimeRaw() error = %v", err) } units := protocol.Parsed["data_units"].([]any) if len(units) != 2 { t.Fatalf("expected merged gb32960 units, got %#v", protocol.Parsed) } if len(realtimeRaw["data_units"].([]any)) != 2 { t.Fatalf("realtime-raw did not keep merged parsed fields: %#v", realtimeRaw) } merged, err := repo.GetMerged(ctx, "VIN001") if err != nil { t.Fatalf("GetMerged() error = %v", err) } mergedJSON, err := json.Marshal(merged) if err != nil { t.Fatal(err) } if strings.Contains(string(mergedJSON), "protocol_data") { t.Fatalf("merged snapshot should not duplicate full protocol parsed data: %s", string(mergedJSON)) } } func TestRepositoryMergesNestedGB32960MotorSlicesBySerialNo(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() ctx := context.Background() if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, Parsed: map[string]any{ "data_units": []any{ map[string]any{ "type": "0x02", "name": "drive_motor", "value": map[string]any{ "motors": []any{ map[string]any{"serial_no": 1, "speed_rpm": 5000}, map[string]any{"serial_no": 2, "speed_rpm": 5100}, }, }, }, }, }, }); err != nil { t.Fatalf("Update() error = %v", err) } if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventTimeMS: 1100, ReceivedAtMS: 1200, Parsed: map[string]any{ "data_units": []any{ map[string]any{ "type": "0x02", "name": "drive_motor", "value": map[string]any{ "motors": []any{ map[string]any{"serial_no": 1, "speed_rpm": 5406}, map[string]any{"serial_no": 2, "speed_rpm": 5377}, }, }, }, }, }, }); err != nil { t.Fatalf("Update() error = %v", err) } realtimeRaw, err := repo.GetRealtimeRaw(ctx, "VIN001", envelope.ProtocolGB32960) if err != nil { t.Fatalf("GetRealtimeRaw() error = %v", err) } units := realtimeRaw["data_units"].([]any) driveMotor := units[0].(map[string]any) value := driveMotor["value"].(map[string]any) motors := value["motors"].([]any) if len(motors) != 2 { t.Fatalf("motors len = %d, want 2: %#v", len(motors), motors) } first := motors[0].(map[string]any) if first["speed_rpm"] != float64(5406) { t.Fatalf("first motor was not updated: %#v", motors) } } func TestRepositoryReplacesJT808LocationWhenUpdatingRealtimeRaw(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() ctx := context.Background() if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, MessageID: "0x0200", Parsed: map[string]any{ "header": map[string]any{ "message_id": "0x0200", "phone_raw": "013307795425", "phone_bcd_hex": "013307795425", }, "location": map[string]any{ "additional": []any{ map[string]any{"id": "0x01", "parsed": map[string]any{"raw_value_tenth_km": 102412}}, }, "total_mileage_km": 10241.2, }, }, Fields: map[string]any{envelope.FieldTotalMileageKM: 10241.2}, }); err != nil { t.Fatalf("Update() initial error = %v", err) } if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventTimeMS: 2000, ReceivedAtMS: 2100, MessageID: "0x0200", Parsed: map[string]any{ "header": map[string]any{ "message_id": "0x0200", "phone": "13307795425", }, "location": map[string]any{ "total_mileage_km": 10241.3, "network_signal_strength": byte(31), }, }, Fields: map[string]any{envelope.FieldTotalMileageKM: 10241.3}, }); err != nil { t.Fatalf("Update() replacement error = %v", err) } realtimeRaw, err := repo.GetRealtimeRaw(ctx, "VIN001", envelope.ProtocolJT808) if err != nil { t.Fatalf("GetRealtimeRaw() error = %v", err) } header := realtimeRaw["header"].(map[string]any) if _, ok := header["phone_raw"]; ok { t.Fatalf("header should be replaced, got %#v", header) } location := realtimeRaw["location"].(map[string]any) if _, ok := location["additional"]; ok { t.Fatalf("jt808 location should be replaced, got %#v", location) } if location["total_mileage_km"] != float64(10241.3) { t.Fatalf("mileage = %#v", location["total_mileage_km"]) } } func TestRepositoryOnlineStatus(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() ctx := context.Background() status, err := repo.IsOnline(ctx, "VIN001") if err != nil { t.Fatalf("IsOnline() error = %v", err) } if status.Online { t.Fatal("empty vin should be offline") } if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, Fields: map[string]any{envelope.FieldSpeedKMH: 12.3}, }); err != nil { t.Fatalf("Update() error = %v", err) } status, err = repo.IsOnline(ctx, "VIN001") if err != nil { t.Fatalf("IsOnline() error = %v", err) } if !status.Online || status.LastSeenMS != 1100 { t.Fatalf("online status = %#v", status) } } func TestRepositoryUpdatesPhoneOnlyVehicleKey(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() ctx := context.Background() if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, Phone: "13307811170", EventTimeMS: 1000, ReceivedAtMS: 1100, Fields: map[string]any{ envelope.FieldSpeedKMH: 23.0, envelope.FieldTotalMileageKM: 10003.7, }, }); err != nil { t.Fatalf("Update() error = %v", err) } vehicleKey := "JT808:13307811170" merged, err := repo.GetMerged(ctx, vehicleKey) if err != nil { t.Fatalf("GetMerged() error = %v", err) } if merged.VehicleKey != vehicleKey || merged.VIN != "" { t.Fatalf("unexpected identity: %#v", merged) } if merged.Fields[envelope.FieldTotalMileageKM] != 10003.7 { t.Fatalf("merged fields = %#v", merged.Fields) } status, err := repo.IsOnline(ctx, vehicleKey) if err != nil { t.Fatalf("IsOnline() error = %v", err) } if !status.Online || status.VehicleKey != vehicleKey { t.Fatalf("online status = %#v", status) } } func TestRepositoryDoesNotOverwritePositiveMileageWithZero(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() ctx := context.Background() if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, Fields: map[string]any{ envelope.FieldTotalMileageKM: 12345.6, }, }); err != nil { t.Fatalf("Update() error = %v", err) } if err := repo.Update(ctx, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventTimeMS: 2000, ReceivedAtMS: 2100, Fields: map[string]any{ envelope.FieldSpeedKMH: 22.0, envelope.FieldTotalMileageKM: 0, }, }); err != nil { t.Fatalf("Update() error = %v", err) } merged, err := repo.GetMerged(ctx, "VIN001") if err != nil { t.Fatalf("GetMerged() error = %v", err) } if merged.Fields[envelope.FieldTotalMileageKM] != 12345.6 { t.Fatalf("zero mileage overwrote positive value: %#v", merged.Fields) } if merged.Fields[envelope.FieldSpeedKMH] != 22.0 { t.Fatalf("new speed should still merge: %#v", merged.Fields) } } func TestHandlerReturnsMergedSnapshot(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() if err := repo.Update(context.Background(), envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, Fields: map[string]any{envelope.FieldSpeedKMH: 12.3}, }); err != nil { t.Fatalf("Update() error = %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles/VIN001", nil) rec := httptest.NewRecorder() NewHandler(repo).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } if !stringsContains(rec.Body.String(), `"vin":"VIN001"`) { t.Fatalf("unexpected body: %s", rec.Body.String()) } } func TestHandlerReturnsPhoneOnlyVehicleKeySnapshot(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() if err := repo.Update(context.Background(), envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, Phone: "13307811170", EventTimeMS: 1000, ReceivedAtMS: 1100, Fields: map[string]any{envelope.FieldSpeedKMH: 12.3}, }); err != nil { t.Fatalf("Update() error = %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles/JT808:13307811170", nil) rec := httptest.NewRecorder() NewHandler(repo).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } if !stringsContains(rec.Body.String(), `"vehicle_key":"JT808:13307811170"`) { t.Fatalf("unexpected body: %s", rec.Body.String()) } } func TestHandlerReturnsRealtimeRaw(t *testing.T) { repo, closeFn := newTestRepository(t) defer closeFn() if err := repo.Update(context.Background(), envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, Parsed: map[string]any{ "data_units": []any{ map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 90.0}}, }, }, }); err != nil { t.Fatalf("Update() error = %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles/VIN001/realtime-raw/GB32960", nil) rec := httptest.NewRecorder() NewHandler(repo).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } if !stringsContains(rec.Body.String(), `"soc_percent":90`) { t.Fatalf("unexpected body: %s", rec.Body.String()) } } func newTestRepository(t *testing.T) (*Repository, func()) { t.Helper() server, err := miniredis.Run() if err != nil { t.Fatalf("miniredis.Run() error = %v", err) } client := redis.NewClient(&redis.Options{Addr: server.Addr()}) return NewRepository(client, Config{OnlineTTL: time.Minute}), func() { _ = client.Close() server.Close() } } func stringsContains(value string, pattern string) bool { return strings.Contains(value, pattern) } func protocolNames(protocols []envelope.Protocol) []string { names := make([]string, 0, len(protocols)) for _, protocol := range protocols { names = append(names, string(protocol)) } return names }