diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 66e098ea..d774cf76 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -911,6 +911,27 @@ func TestHandlerVehicleRealtimeAcceptsBatchKeywords(t *testing.T) { } } +func TestHandlerVehicleRealtimeBatchKeywordsAreExact(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?keywords=%E7%B2%A4AG1831&limit=10", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data struct { + Items []VehicleRealtimeRow `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String()) + } + if len(body.Data.Items) != 0 { + t.Fatalf("copied batch plates must not fuzzy-match similar identities, got %+v body=%s", body.Data.Items, rec.Body.String()) + } +} + func TestHandlerHistoryMileageQualityOps(t *testing.T) { cases := []struct { path string diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 7065c158..e8e9a0be 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -486,8 +486,17 @@ func buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM int) [ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { rows := m.locations if keywords := vehicleSearchKeywords(query); len(keywords) > 0 { + batch := strings.TrimSpace(query.Get("keywords")) != "" rows = keep(rows, func(row RealtimeLocationRow) bool { vehicle := m.vehicleByVIN(row.VIN) + if batch { + for _, keyword := range keywords { + if strings.EqualFold(row.VIN, keyword) || strings.EqualFold(row.Plate, keyword) || strings.EqualFold(vehicle.Plate, keyword) { + return true + } + } + return false + } haystack := strings.ToLower(row.VIN + row.Plate + vehicle.Plate + vehicle.Phone + vehicle.OEM) for _, keyword := range keywords { if strings.Contains(haystack, strings.ToLower(keyword)) { diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 87de3c29..5f8e70ae 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -329,13 +329,20 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery { args = append(args, protocol) } if keywords := vehicleSearchKeywords(query); len(keywords) > 0 { - matches := make([]string, 0, len(keywords)) - for _, keyword := range keywords { - matches = append(matches, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") + if strings.TrimSpace(query.Get("keywords")) != "" { + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(keywords)), ",") + where = append(where, "(l.vin IN ("+placeholders+") OR l.plate IN ("+placeholders+") OR b.plate IN ("+placeholders+"))") + for range 3 { + for _, keyword := range keywords { + args = append(args, keyword) + } + } + } else { + keyword := keywords[0] like := "%" + keyword + "%" + where = append(where, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") args = append(args, like, like, like, like, like) } - where = append(where, "("+strings.Join(matches, " OR ")+")") } switch strings.TrimSpace(query.Get("online")) { case "online": diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 6ca1ec6c..b1dec9b0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -3,6 +3,7 @@ package platform import ( "fmt" "net/url" + "slices" "strings" "testing" "time" @@ -217,17 +218,39 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) { func TestBuildVehicleRealtimeSQLFiltersMultipleVehicleKeywords(t *testing.T) { built := buildVehicleRealtimeSQL(url.Values{"keywords": {"粤AG18312, 川AHTWO1, 粤AG18312"}, "limit": {"10"}}) - if !strings.Contains(built.Text, ") OR (l.vin LIKE ?") || !strings.Contains(built.CountText, ") OR (l.vin LIKE ?") { - t.Fatalf("batch vehicle search must match any keyword in rows and count: %s / %s", built.Text, built.CountText) + for _, text := range []string{built.Text, built.CountText} { + if !strings.Contains(text, "l.vin IN (?,?)") || !strings.Contains(text, "l.plate IN (?,?)") || !strings.Contains(text, "b.plate IN (?,?)") { + t.Fatalf("batch vehicle search must use exact identity sets in rows and count: %s", text) + } + if strings.Contains(text, "LIKE") { + t.Fatalf("batch vehicle search must not expand copied plates into fuzzy predicates: %s", text) + } } - if len(built.Args) != 12 || built.Args[0] != "%粤AG18312%" || built.Args[5] != "%川AHTWO1%" || built.Args[10] != 10 { + if len(built.Args) != 8 || built.Args[0] != "粤AG18312" || built.Args[1] != "川AHTWO1" || built.Args[2] != "粤AG18312" || built.Args[6] != 10 { t.Fatalf("batch args = %#v", built.Args) } - if len(built.CountArgs) != 10 || built.CountArgs[0] != "%粤AG18312%" || built.CountArgs[5] != "%川AHTWO1%" { + if len(built.CountArgs) != 6 || built.CountArgs[0] != "粤AG18312" || built.CountArgs[1] != "川AHTWO1" || built.CountArgs[4] != "粤AG18312" { t.Fatalf("batch count args = %#v", built.CountArgs) } } +func TestBuildVehicleRealtimeSQLBoundsCopiedPlateSet(t *testing.T) { + keywords := make([]string, 0, vehicleSearchKeywordLimit+10) + for index := 0; index < vehicleSearchKeywordLimit+10; index++ { + keywords = append(keywords, fmt.Sprintf("粤A%05d", index)) + } + built := buildVehicleRealtimeSQL(url.Values{"keywords": {strings.Join(keywords, ",")}, "limit": {"10"}}) + if len(built.Args) != vehicleSearchKeywordLimit*3+2 { + t.Fatalf("bounded batch args = %d, want %d", len(built.Args), vehicleSearchKeywordLimit*3+2) + } + if len(built.CountArgs) != vehicleSearchKeywordLimit*3 { + t.Fatalf("bounded batch count args = %d, want %d", len(built.CountArgs), vehicleSearchKeywordLimit*3) + } + if slices.Contains(built.Args, "粤A00100") { + t.Fatalf("batch query must ignore identities beyond the %d item boundary", vehicleSearchKeywordLimit) + } +} + func TestBuildVehicleRealtimeSQLFiltersServiceStatus(t *testing.T) { query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}} built := buildVehicleRealtimeSQL(query)