diff --git a/services/search/pkg/mapping/bleve_test.go b/services/search/pkg/mapping/bleve_test.go index 59d58fd6b9..a9cd81954f 100644 --- a/services/search/pkg/mapping/bleve_test.go +++ b/services/search/pkg/mapping/bleve_test.go @@ -2,8 +2,10 @@ package mapping import ( "reflect" - "testing" "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) type bleveDoc struct { @@ -21,140 +23,93 @@ type nested struct { Year int `json:"year"` } -// bleve wildcard falls back to keyword-ish text (bleve has no wildcard type). -func TestBleveWildcardFallback(t *testing.T) { - type doc struct { - Mime string `json:"mime"` - } - dm, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"mime": {Type: TypeWildcard}}) - if err != nil { - t.Fatalf("BleveBuildMapping: %v", err) - } - fms := dm.Properties["mime"].Fields - if len(fms) != 1 || fms[0].Type != "text" { - t.Fatalf("wildcard should map to text, got %+v", fms) - } -} - -func TestBleveBuildMappingInferredTypes(t *testing.T) { - dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil) - if err != nil { - t.Fatalf("BleveBuildMapping: %v", err) - } - cases := map[string]string{ - "Name": "text", - "Content": "text", - "Tags": "text", - "Size": "number", - "Deleted": "boolean", - "CreatedAt": "datetime", - } - for field, wantType := range cases { - prop := dm.Properties[field] - if prop == nil { - t.Errorf("missing property %q", field) - continue +var _ = Describe("BleveBuildMapping", func() { + It("falls back to text for wildcard fields", func() { + // bleve wildcard falls back to keyword-ish text (bleve has no wildcard type). + type doc struct { + Mime string `json:"mime"` } - if len(prop.Fields) == 0 { - t.Errorf("%q: no field mappings", field) - continue - } - if got := prop.Fields[0].Type; got != wantType { - t.Errorf("%q: got type %q, want %q", field, got, wantType) - } - } -} - -func TestBleveBuildMappingNestedIsSubDocument(t *testing.T) { - dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil) - if err != nil { - t.Fatalf("BleveBuildMapping: %v", err) - } - sub := dm.Properties["nested"] - if sub == nil { - t.Fatal("missing nested sub-document") - } - if sub.Properties["artist"] == nil || sub.Properties["year"] == nil { - t.Fatalf("nested fields missing: %#v", sub.Properties) - } - if got := sub.Properties["artist"].Fields[0].Type; got != "text" { - t.Errorf("nested.artist: type %q, want text", got) - } - if got := sub.Properties["year"].Fields[0].Type; got != "number" { - t.Errorf("nested.year: type %q, want number", got) - } -} - -func TestBleveBuildMappingOverrides(t *testing.T) { - includeInAllFalse := false - dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), map[string]FieldOpts{ - "Name": {Analyzer: "lowercaseKeyword"}, - "Content": {Type: TypeFulltext}, - "Tags": {Analyzer: "lowercaseKeyword", IncludeInAll: &includeInAllFalse}, + dm, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"mime": {Type: TypeWildcard}}) + Expect(err).ToNot(HaveOccurred()) + fms := dm.Properties["mime"].Fields + Expect(fms).To(HaveLen(1)) + Expect(fms[0].Type).To(Equal("text"), "wildcard should map to text") }) - if err != nil { - t.Fatalf("BleveBuildMapping: %v", err) - } - nameField := dm.Properties["Name"].Fields[0] - if nameField.Analyzer != "lowercaseKeyword" { - t.Errorf("Name analyzer: %q, want lowercaseKeyword", nameField.Analyzer) - } - if !nameField.IncludeInAll { - t.Errorf("Name IncludeInAll should stay default-true when not overridden") - } - contentField := dm.Properties["Content"].Fields[0] - if contentField.Analyzer != "fulltext" { - t.Errorf("Content analyzer: %q, want fulltext", contentField.Analyzer) - } - if contentField.IncludeInAll { - t.Errorf("Content IncludeInAll should default to false for fulltext type") - } - tagsField := dm.Properties["Tags"].Fields[0] - if tagsField.IncludeInAll { - t.Errorf("Tags IncludeInAll should honor the explicit false override") - } -} -func TestBleveBuildMappingGeopoint(t *testing.T) { - type geoDoc struct { - Location *struct { - Lon *float64 `json:"longitude,omitempty"` - Lat *float64 `json:"latitude,omitempty"` - Alt *float64 `json:"altitude,omitempty"` - } `json:"location,omitempty"` - } - dm, err := BleveBuildMapping(reflect.TypeFor[geoDoc](), map[string]FieldOpts{ - "location": {Type: TypeGeopoint}, + DescribeTable("infers field types", + func(field, wantType string) { + dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil) + Expect(err).ToNot(HaveOccurred()) + prop := dm.Properties[field] + Expect(prop).ToNot(BeNil(), "missing property %q", field) + Expect(prop.Fields).ToNot(BeEmpty(), "%q: no field mappings", field) + Expect(prop.Fields[0].Type).To(Equal(wantType), "%q type", field) + }, + Entry("Name", "Name", "text"), + Entry("Content", "Content", "text"), + Entry("Tags", "Tags", "text"), + Entry("Size", "Size", "number"), + Entry("Deleted", "Deleted", "boolean"), + Entry("CreatedAt", "CreatedAt", "datetime"), + ) + + It("maps a nested struct as a sub-document", func() { + dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil) + Expect(err).ToNot(HaveOccurred()) + sub := dm.Properties["nested"] + Expect(sub).ToNot(BeNil(), "missing nested sub-document") + Expect(sub.Properties["artist"]).ToNot(BeNil()) + Expect(sub.Properties["year"]).ToNot(BeNil()) + Expect(sub.Properties["artist"].Fields[0].Type).To(Equal("text"), "nested.artist") + Expect(sub.Properties["year"].Fields[0].Type).To(Equal("number"), "nested.year") }) - if err != nil { - t.Fatalf("BleveBuildMapping: %v", err) - } - // Original facet stays as an object sub-document with numeric - // sub-properties - for data retrieval via hit.Fields and ordinary - // numeric queries. - loc := dm.Properties["location"] - if loc == nil { - t.Fatalf("location sub-document missing: %#v", dm.Properties) - } - if len(loc.Fields) != 0 { - t.Errorf("location should not carry field mappings directly, got %#v", loc.Fields) - } - for _, sub := range []string{"longitude", "latitude", "altitude"} { - prop, ok := loc.Properties[sub] - if !ok { - t.Errorf("missing sub-field %q under location (properties: %v)", sub, loc.Properties) - continue + + It("applies field overrides", func() { + includeInAllFalse := false + dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), map[string]FieldOpts{ + "Name": {Analyzer: "lowercaseKeyword"}, + "Content": {Type: TypeFulltext}, + "Tags": {Analyzer: "lowercaseKeyword", IncludeInAll: &includeInAllFalse}, + }) + Expect(err).ToNot(HaveOccurred()) + nameField := dm.Properties["Name"].Fields[0] + Expect(nameField.Analyzer).To(Equal("lowercaseKeyword"), "Name analyzer") + Expect(nameField.IncludeInAll).To(BeTrue(), "Name IncludeInAll should stay default-true when not overridden") + contentField := dm.Properties["Content"].Fields[0] + Expect(contentField.Analyzer).To(Equal("fulltext"), "Content analyzer") + Expect(contentField.IncludeInAll).To(BeFalse(), "Content IncludeInAll should default to false for fulltext type") + tagsField := dm.Properties["Tags"].Fields[0] + Expect(tagsField.IncludeInAll).To(BeFalse(), "Tags IncludeInAll should honor the explicit false override") + }) + + It("builds an object sub-document plus a geopoint sibling", func() { + type geoDoc struct { + Location *struct { + Lon *float64 `json:"longitude,omitempty"` + Lat *float64 `json:"latitude,omitempty"` + Alt *float64 `json:"altitude,omitempty"` + } `json:"location,omitempty"` } - if len(prop.Fields) == 0 || prop.Fields[0].Type != "number" { - t.Errorf("location.%s Fields: %#v, want [number]", sub, prop.Fields) + dm, err := BleveBuildMapping(reflect.TypeFor[geoDoc](), map[string]FieldOpts{ + "location": {Type: TypeGeopoint}, + }) + Expect(err).ToNot(HaveOccurred()) + // Original facet stays as an object sub-document with numeric + // sub-properties - for data retrieval via hit.Fields and ordinary + // numeric queries. + loc := dm.Properties["location"] + Expect(loc).ToNot(BeNil(), "location sub-document missing") + Expect(loc.Fields).To(BeEmpty(), "location should not carry field mappings directly") + for _, sub := range []string{"longitude", "latitude", "altitude"} { + prop, ok := loc.Properties[sub] + Expect(ok).To(BeTrue(), "missing sub-field %q under location", sub) + Expect(prop.Fields).ToNot(BeEmpty(), "location.%s Fields", sub) + Expect(prop.Fields[0].Type).To(Equal("number"), "location.%s type", sub) } - } - // Sibling geopoint at "_geopoint" for geo-distance queries. - sibling := dm.Properties["location"+GeopointSuffix] - if sibling == nil { - t.Fatalf("location%s missing: %#v", GeopointSuffix, dm.Properties) - } - if len(sibling.Fields) == 0 || sibling.Fields[0].Type != "geopoint" { - t.Errorf("location%s Fields: %#v, want [geopoint]", GeopointSuffix, sibling.Fields) - } -} + // Sibling geopoint at "_geopoint" for geo-distance queries. + sibling := dm.Properties["location"+GeopointSuffix] + Expect(sibling).ToNot(BeNil(), "location%s missing", GeopointSuffix) + Expect(sibling.Fields).ToNot(BeEmpty(), "location%s Fields", GeopointSuffix) + Expect(sibling.Fields[0].Type).To(Equal("geopoint"), "location%s type", GeopointSuffix) + }) +}) diff --git a/services/search/pkg/mapping/deserialize_string_test.go b/services/search/pkg/mapping/deserialize_string_test.go index 981f5e50a5..9fed25b4d9 100644 --- a/services/search/pkg/mapping/deserialize_string_test.go +++ b/services/search/pkg/mapping/deserialize_string_test.go @@ -2,9 +2,10 @@ package mapping import ( "reflect" - "testing" "time" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -17,106 +18,81 @@ type stringFacet struct { Taken *time.Time `json:"takenDateTime,omitempty"` } -func TestSetValueFromStringUnsupportedKind(t *testing.T) { - v := reflect.New(reflect.TypeFor[[]int]()).Elem() // settable slice - if err := setValueFromString(v, "x"); err == nil { - t.Error("expected error for unsupported target kind (slice)") - } -} +var _ = Describe("DeserializeStringsAt", func() { + It("errors on an unsupported target kind", func() { + v := reflect.New(reflect.TypeFor[[]int]()).Elem() // settable slice + Expect(setValueFromString(v, "x")).To(HaveOccurred(), "expected error for unsupported target kind (slice)") + }) -func TestDeserializeStringsAtBasicTypes(t *testing.T) { - r := DeserializeStringsAt[stringFacet](map[string]string{ - "libre.graph.audio.artist": "Queen", - "libre.graph.audio.year": "1975", - "libre.graph.audio.duration": "354000", - "libre.graph.audio.rating": "4.9", - "libre.graph.audio.explicit": "true", - "libre.graph.audio.takenDateTime": "2024-01-02T03:04:05Z", - }, "libre.graph.audio.") - if r == nil { - t.Fatal("expected non-nil *stringFacet") - } - if r.Artist == nil || *r.Artist != "Queen" { - t.Errorf("Artist: %#v", r.Artist) - } - if r.Year == nil || *r.Year != 1975 { - t.Errorf("Year: %#v", r.Year) - } - if r.Duration == nil || *r.Duration != 354000 { - t.Errorf("Duration: %#v", r.Duration) - } - if r.Rating == nil || *r.Rating != 4.9 { - t.Errorf("Rating: %#v", r.Rating) - } - if r.Explicit == nil || !*r.Explicit { - t.Errorf("Explicit: %#v", r.Explicit) - } - if r.Taken == nil || !r.Taken.Equal(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)) { - t.Errorf("Taken: %#v", r.Taken) - } -} + It("parses basic types", func() { + r := DeserializeStringsAt[stringFacet](map[string]string{ + "libre.graph.audio.artist": "Queen", + "libre.graph.audio.year": "1975", + "libre.graph.audio.duration": "354000", + "libre.graph.audio.rating": "4.9", + "libre.graph.audio.explicit": "true", + "libre.graph.audio.takenDateTime": "2024-01-02T03:04:05Z", + }, "libre.graph.audio.") + Expect(r).ToNot(BeNil()) + Expect(r.Artist).ToNot(BeNil()) + Expect(*r.Artist).To(Equal("Queen")) + Expect(r.Year).ToNot(BeNil()) + Expect(*r.Year).To(Equal(int32(1975))) + Expect(r.Duration).ToNot(BeNil()) + Expect(*r.Duration).To(Equal(int64(354000))) + Expect(r.Rating).ToNot(BeNil()) + Expect(*r.Rating).To(Equal(4.9)) + Expect(r.Explicit).ToNot(BeNil()) + Expect(*r.Explicit).To(BeTrue()) + Expect(r.Taken).ToNot(BeNil()) + Expect(r.Taken.Equal(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))).To(BeTrue(), "Taken: %#v", r.Taken) + }) -func TestDeserializeStringsAtReturnsNilWhenEmpty(t *testing.T) { - r := DeserializeStringsAt[stringFacet](map[string]string{ - "libre.graph.image.width": "1200", - }, "libre.graph.audio.") - if r != nil { - t.Fatalf("expected nil, got %#v", r) - } -} + It("returns nil when nothing matches the prefix", func() { + r := DeserializeStringsAt[stringFacet](map[string]string{ + "libre.graph.image.width": "1200", + }, "libre.graph.audio.") + Expect(r).To(BeNil()) + }) -func TestDeserializeStringsAtTimestamppb(t *testing.T) { - type photoFacet struct { - Taken *timestamppb.Timestamp `json:"takenDateTime,omitempty"` - } - r := DeserializeStringsAt[photoFacet](map[string]string{ - "libre.graph.photo.takenDateTime": "2024-05-06T07:08:09Z", - }, "libre.graph.photo.") - if r == nil || r.Taken == nil { - t.Fatalf("Taken missing: %#v", r) - } - want := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC) - if !r.Taken.AsTime().Equal(want) { - t.Errorf("Taken: got %v, want %v", r.Taken.AsTime(), want) - } -} - -func TestDeserializeStringsAtIsFailSoft(t *testing.T) { - // A single malformed field (year is unparseable as int) must not drop - // the whole facet. The bad field stays at zero value, the rest of the - // facet still populates. Mirrors the bleve-hit Deserialize behavior. - r := DeserializeStringsAt[stringFacet](map[string]string{ - "libre.graph.audio.artist": "Iron Maiden", - "libre.graph.audio.year": "not-a-number", - "libre.graph.audio.duration": "354000", - "libre.graph.audio.explicit": "not-a-bool", - "libre.graph.audio.rating": "4.9", - }, "libre.graph.audio.") - if r == nil { - t.Fatal("expected non-nil *stringFacet despite bad fields") - } - if r.Artist == nil || *r.Artist != "Iron Maiden" { - t.Errorf("Artist should still be populated, got %#v", r.Artist) - } - if r.Duration == nil || *r.Duration != 354000 { - t.Errorf("Duration should still be populated, got %#v", r.Duration) - } - if r.Rating == nil || *r.Rating != 4.9 { - t.Errorf("Rating should still be populated, got %#v", r.Rating) - } - if r.Year != nil { - t.Errorf("Year should stay nil for bad int, got %#v", r.Year) - } - if r.Explicit != nil { - t.Errorf("Explicit should stay nil for bad bool, got %#v", r.Explicit) - } -} - -func TestDeserializeStringsAtPanicsOnNonStruct(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for non-struct T") + It("parses into a timestamppb.Timestamp", func() { + type photoFacet struct { + Taken *timestamppb.Timestamp `json:"takenDateTime,omitempty"` } - }() - DeserializeStringsAt[int](nil, "") -} + r := DeserializeStringsAt[photoFacet](map[string]string{ + "libre.graph.photo.takenDateTime": "2024-05-06T07:08:09Z", + }, "libre.graph.photo.") + Expect(r).ToNot(BeNil()) + Expect(r.Taken).ToNot(BeNil()) + want := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC) + Expect(r.Taken.AsTime().Equal(want)).To(BeTrue(), "Taken: got %v, want %v", r.Taken.AsTime(), want) + }) + + It("is fail-soft on malformed fields", func() { + // A single malformed field (year is unparseable as int) must not drop + // the whole facet. The bad field stays at zero value, the rest of the + // facet still populates. Mirrors the bleve-hit Deserialize behavior. + r := DeserializeStringsAt[stringFacet](map[string]string{ + "libre.graph.audio.artist": "Iron Maiden", + "libre.graph.audio.year": "not-a-number", + "libre.graph.audio.duration": "354000", + "libre.graph.audio.explicit": "not-a-bool", + "libre.graph.audio.rating": "4.9", + }, "libre.graph.audio.") + Expect(r).ToNot(BeNil()) + Expect(r.Artist).ToNot(BeNil()) + Expect(*r.Artist).To(Equal("Iron Maiden"), "Artist should still be populated") + Expect(r.Duration).ToNot(BeNil()) + Expect(*r.Duration).To(Equal(int64(354000)), "Duration should still be populated") + Expect(r.Rating).ToNot(BeNil()) + Expect(*r.Rating).To(Equal(4.9), "Rating should still be populated") + Expect(r.Year).To(BeNil(), "Year should stay nil for bad int") + Expect(r.Explicit).To(BeNil(), "Explicit should stay nil for bad bool") + }) + + It("panics for a non-struct T", func() { + Expect(func() { + DeserializeStringsAt[int](nil, "") + }).To(Panic()) + }) +}) diff --git a/services/search/pkg/mapping/deserialize_test.go b/services/search/pkg/mapping/deserialize_test.go index ec92191ce3..55824aeaa5 100644 --- a/services/search/pkg/mapping/deserialize_test.go +++ b/services/search/pkg/mapping/deserialize_test.go @@ -1,9 +1,10 @@ package mapping import ( - "testing" "time" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -31,125 +32,87 @@ type embedded struct { Photo *photo `json:"photo,omitempty"` } -func TestDeserializeAtNonStructPanics(t *testing.T) { - defer func() { - if recover() == nil { - t.Error("expected panic for non-struct type") - } - }() - _ = DeserializeAt[int](map[string]any{}, "") -} - -func TestDeserializeLeafFields(t *testing.T) { - r := Deserialize[Leaf](map[string]any{ - "Name": "n", - "Size": float64(42), - "Deleted": true, +var _ = Describe("Deserialize", func() { + It("panics for a non-struct type at a prefix", func() { + Expect(func() { + _ = DeserializeAt[int](map[string]any{}, "") + }).To(Panic()) }) - if r.Name != "n" || r.Size != 42 || !r.Deleted { - t.Fatalf("got %#v", r) - } -} -func TestDeserializeScalarToSlice(t *testing.T) { - r := Deserialize[Leaf](map[string]any{ - "Tags": "single", - "Favorites": []any{"a", "b"}, + It("panics for a non-struct T", func() { + Expect(func() { + Deserialize[int](nil) + }).To(Panic()) }) - if len(r.Tags) != 1 || r.Tags[0] != "single" { - t.Errorf("Tags: %#v", r.Tags) - } - if len(r.Favorites) != 2 || r.Favorites[0] != "a" || r.Favorites[1] != "b" { - t.Errorf("Favorites: %#v", r.Favorites) - } -} -func TestDeserializeTimestamp(t *testing.T) { - r := Deserialize[embedded](map[string]any{ - "photo.takenDateTime": "2024-01-02T03:04:05Z", - "photo.mtime": "2024-05-06T07:08:09Z", + It("deserializes leaf fields", func() { + r := Deserialize[Leaf](map[string]any{ + "Name": "n", + "Size": float64(42), + "Deleted": true, + }) + Expect(r.Name).To(Equal("n")) + Expect(r.Size).To(Equal(uint64(42))) + Expect(r.Deleted).To(BeTrue()) }) - if r.Photo == nil { - t.Fatal("Photo is nil") - } - if r.Photo.Taken == nil { - t.Fatal("Taken is nil") - } - expected := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) - if !r.Photo.Taken.AsTime().Equal(expected) { - t.Errorf("Taken: got %v, want %v", r.Photo.Taken.AsTime(), expected) - } - if r.Photo.Mtime == nil { - t.Fatal("Mtime is nil") - } - if !r.Photo.Mtime.Equal(time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)) { - t.Errorf("Mtime: %v", r.Photo.Mtime) - } -} -func TestDeserializeIsFailSoft(t *testing.T) { - // Malformed values (type mismatch, unparseable time) leave the - // affected field at its zero value instead of dropping the whole - // record. Matches the pre-refactor getFieldValue behavior so - // matchToResource never returns nil on a corrupted hit. - r := Deserialize[embedded](map[string]any{ - "Name": "n", - "Size": "not-a-number", // wrong type - "Deleted": true, - "photo.takenDateTime": "not-an-rfc3339-time", - "photo.mtime": "2024-05-06T07:08:09Z", + It("coerces a scalar into a slice field", func() { + r := Deserialize[Leaf](map[string]any{ + "Tags": "single", + "Favorites": []any{"a", "b"}, + }) + Expect(r.Tags).To(Equal([]string{"single"})) + Expect(r.Favorites).To(Equal([]string{"a", "b"})) }) - if r == nil { - t.Fatal("expected non-nil *embedded even with partial corruption") - } - if r.Name != "n" { - t.Errorf("Name: %q", r.Name) - } - if r.Size != 0 { - t.Errorf("Size should stay zero on mismatch, got %d", r.Size) - } - if !r.Deleted { - t.Errorf("Deleted should still be true") - } - if r.Photo == nil { - t.Fatal("Photo should be populated because Mtime parsed ok") - } - if r.Photo.Taken != nil { - t.Errorf("Taken should stay nil for unparseable time, got %v", r.Photo.Taken) - } - if r.Photo.Mtime == nil { - t.Error("Mtime should be parsed") - } -} -func TestDeserializePanicsOnNonStruct(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for non-struct T") - } - }() - Deserialize[int](nil) -} + It("parses timestamps", func() { + r := Deserialize[embedded](map[string]any{ + "photo.takenDateTime": "2024-01-02T03:04:05Z", + "photo.mtime": "2024-05-06T07:08:09Z", + }) + Expect(r.Photo).ToNot(BeNil()) + Expect(r.Photo.Taken).ToNot(BeNil()) + expected := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + Expect(r.Photo.Taken.AsTime().Equal(expected)).To(BeTrue(), "Taken: got %v, want %v", r.Photo.Taken.AsTime(), expected) + Expect(r.Photo.Mtime).ToNot(BeNil()) + Expect(r.Photo.Mtime.Equal(time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC))).To(BeTrue(), "Mtime: %v", r.Photo.Mtime) + }) -func TestDeserializeAtReturnsNilWhenNothingMatches(t *testing.T) { - r := DeserializeAt[audio](map[string]any{"Name": "n"}, "audio") - if r != nil { - t.Fatalf("expected nil, got %#v", r) - } -} + It("is fail-soft on malformed values", func() { + // Malformed values (type mismatch, unparseable time) leave the + // affected field at its zero value instead of dropping the whole + // record. Matches the pre-refactor getFieldValue behavior so + // matchToResource never returns nil on a corrupted hit. + r := Deserialize[embedded](map[string]any{ + "Name": "n", + "Size": "not-a-number", // wrong type + "Deleted": true, + "photo.takenDateTime": "not-an-rfc3339-time", + "photo.mtime": "2024-05-06T07:08:09Z", + }) + Expect(r).ToNot(BeNil()) + Expect(r.Name).To(Equal("n")) + Expect(r.Size).To(Equal(uint64(0)), "Size should stay zero on mismatch") + Expect(r.Deleted).To(BeTrue()) + Expect(r.Photo).ToNot(BeNil(), "Photo should be populated because Mtime parsed ok") + Expect(r.Photo.Taken).To(BeNil(), "Taken should stay nil for unparseable time") + Expect(r.Photo.Mtime).ToNot(BeNil(), "Mtime should be parsed") + }) -func TestDeserializeAtReturnsValueWhenPrefixMatches(t *testing.T) { - r := DeserializeAt[audio](map[string]any{ - "audio.artist": "A", - "audio.year": float64(2024), // setValue: pointer + numeric convert - }, "audio") - if r == nil { - t.Fatal("expected non-nil *audio") - } - if r.Artist == nil || *r.Artist != "A" { - t.Errorf("Artist: %#v", r.Artist) - } - if r.Year == nil || *r.Year != 2024 { - t.Errorf("Year: %#v", r.Year) - } -} + It("returns nil when nothing matches the prefix", func() { + r := DeserializeAt[audio](map[string]any{"Name": "n"}, "audio") + Expect(r).To(BeNil()) + }) + + It("returns a value when the prefix matches", func() { + r := DeserializeAt[audio](map[string]any{ + "audio.artist": "A", + "audio.year": float64(2024), // setValue: pointer + numeric convert + }, "audio") + Expect(r).ToNot(BeNil()) + Expect(r.Artist).ToNot(BeNil()) + Expect(*r.Artist).To(Equal("A")) + Expect(r.Year).ToNot(BeNil()) + Expect(*r.Year).To(Equal(int32(2024))) + }) +}) diff --git a/services/search/pkg/mapping/fillstruct_test.go b/services/search/pkg/mapping/fillstruct_test.go index 4e5856fe80..70727d8bb7 100644 --- a/services/search/pkg/mapping/fillstruct_test.go +++ b/services/search/pkg/mapping/fillstruct_test.go @@ -3,7 +3,9 @@ package mapping import ( "errors" "reflect" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) // The walker is shared by both deserializers, so its structural behavior @@ -45,83 +47,60 @@ func fsSet(v reflect.Value, raw string) error { return nil } -func TestFillStruct(t *testing.T) { +var _ = Describe("fillStruct", func() { fill := func(fields map[string]string, prefix string) (fsRoot, bool) { var root fsRoot touched := fillStruct(reflect.ValueOf(&root).Elem(), fields, prefix, fsSet) return root, touched } - t.Run("flattens embedded, recurses nested", func(t *testing.T) { + It("flattens embedded and recurses nested", func() { root, touched := fill(map[string]string{ "leaf": "L", "ev": "EV", "ep": "EP", "nested.n": "N", }, "") - if !touched { - t.Fatal("expected touched") - } - if root.Leaf != "L" { - t.Errorf("Leaf: %q", root.Leaf) - } - if root.EV != "EV" { - t.Errorf("embedded value not promoted: %q", root.EV) - } - if root.FsEmbPtr == nil || root.EP != "EP" { - t.Errorf("embedded pointer not allocated: %+v", root.FsEmbPtr) - } - if root.Nested == nil || root.Nested.N != "N" { - t.Errorf("nested pointer not populated: %+v", root.Nested) - } + Expect(touched).To(BeTrue()) + Expect(root.Leaf).To(Equal("L")) + Expect(root.EV).To(Equal("EV"), "embedded value not promoted") + Expect(root.FsEmbPtr).ToNot(BeNil(), "embedded pointer not allocated") + Expect(root.EP).To(Equal("EP")) + Expect(root.Nested).ToNot(BeNil(), "nested pointer not populated") + Expect(root.Nested.N).To(Equal("N")) }) - t.Run("nothing matches: touched false, pointers stay nil", func(t *testing.T) { + It("leaves touched false and pointers nil when nothing matches", func() { root, touched := fill(map[string]string{"other": "x"}, "") - if touched { - t.Fatal("expected untouched") - } - if root.Nested != nil { - t.Errorf("Nested should stay nil: %+v", root.Nested) - } - if root.FsEmbPtr != nil { - t.Errorf("embedded pointer should stay nil: %+v", root.FsEmbPtr) - } + Expect(touched).To(BeFalse()) + Expect(root.Nested).To(BeNil(), "Nested should stay nil") + Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil") }) - t.Run("prefix arg is joined with the field name", func(t *testing.T) { + It("joins the prefix arg with the field name", func() { root, touched := fill(map[string]string{ "pre.leaf": "L", "pre.nested.n": "N", }, "pre") - if !touched || root.Leaf != "L" || root.Nested == nil || root.Nested.N != "N" { - t.Fatalf("prefix not joined: leaf=%q nested=%+v", root.Leaf, root.Nested) - } + Expect(touched).To(BeTrue()) + Expect(root.Leaf).To(Equal("L")) + Expect(root.Nested).ToNot(BeNil()) + Expect(root.Nested.N).To(Equal("N")) }) - t.Run("fail-soft: errored leaf stays zero, walk continues", func(t *testing.T) { + It("is fail-soft: errored leaf stays zero, walk continues", func() { root, touched := fill(map[string]string{ "leaf": "BAD", "ev": "EV", }, "") - if !touched { - t.Fatal("expected touched because ev was set") - } - if root.Leaf != "" { - t.Errorf("errored leaf should stay zero, got %q", root.Leaf) - } - if root.EV != "EV" { - t.Errorf("walk should continue past the error: %q", root.EV) - } + Expect(touched).To(BeTrue(), "expected touched because ev was set") + Expect(root.Leaf).To(BeEmpty(), "errored leaf should stay zero") + Expect(root.EV).To(Equal("EV"), "walk should continue past the error") }) - t.Run("embedded pointer dropped when its only field errors", func(t *testing.T) { + It("drops the embedded pointer when its only field errors", func() { root, touched := fill(map[string]string{"ep": "BAD"}, "") - if touched { - t.Fatal("expected untouched") - } - if root.FsEmbPtr != nil { - t.Errorf("embedded pointer should stay nil on error, got %+v", root.FsEmbPtr) - } + Expect(touched).To(BeFalse()) + Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil on error") }) -} +}) diff --git a/services/search/pkg/mapping/geo_test.go b/services/search/pkg/mapping/geo_test.go index 78cd3b5af7..c0eac54fef 100644 --- a/services/search/pkg/mapping/geo_test.go +++ b/services/search/pkg/mapping/geo_test.go @@ -2,159 +2,142 @@ package mapping import ( "reflect" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -// build-mapping error paths: an override that doesn't fit the Go field. -func TestBuildMappingErrors(t *testing.T) { - type doc struct { - Name string `json:"name"` - } - // Geopoint on a non-struct field must error on both backends. - if _, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}}); err == nil { - t.Error("bleve: expected error for geopoint on string field") - } - if _, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}}); err == nil { - t.Error("opensearch: expected error for geopoint on string field") - } -} +var _ = Describe("BuildMapping geopoint errors", func() { + // build-mapping error paths: an override that doesn't fit the Go field. + It("errors for geopoint on a non-struct field on both backends", func() { + type doc struct { + Name string `json:"name"` + } + // Geopoint on a non-struct field must error on both backends. + _, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}}) + Expect(err).To(HaveOccurred(), "bleve: expected error for geopoint on string field") + _, err = OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}}) + Expect(err).To(HaveOccurred(), "opensearch: expected error for geopoint on string field") + }) +}) -func TestAddGeopointSiblingMissingIntermediate(t *testing.T) { - m := map[string]any{"journey": "not-a-map"} - addGeopointSibling(m, "journey.start") // must bail, not panic - if _, ok := m["journey.start"+GeopointSuffix]; ok { - t.Error("no sibling should be written when the path can't be resolved") - } -} +var _ = Describe("addGeopointSibling", func() { + It("bails without panicking when the intermediate is missing", func() { + m := map[string]any{"journey": "not-a-map"} + addGeopointSibling(m, "journey.start") // must bail, not panic + Expect(m).ToNot(HaveKey("journey.start" + GeopointSuffix)) + }) +}) -func TestPrepareForIndexAddsGeopointSibling(t *testing.T) { - type geoDoc struct { - Location *struct { +var _ = Describe("PrepareForIndex geopoint", func() { + It("adds a geopoint sibling", func() { + type geoDoc struct { + Location *struct { + Longitude *float64 `json:"longitude,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Altitude *float64 `json:"altitude,omitempty"` + } `json:"location,omitempty"` + } + lon, lat, alt := 11.1, 49.4, 1047.7 + doc := geoDoc{Location: &struct { Longitude *float64 `json:"longitude,omitempty"` Latitude *float64 `json:"latitude,omitempty"` Altitude *float64 `json:"altitude,omitempty"` - } `json:"location,omitempty"` - } - lon, lat, alt := 11.1, 49.4, 1047.7 - doc := geoDoc{Location: &struct { - Longitude *float64 `json:"longitude,omitempty"` - Latitude *float64 `json:"latitude,omitempty"` - Altitude *float64 `json:"altitude,omitempty"` - }{Longitude: &lon, Latitude: &lat, Altitude: &alt}} + }{Longitude: &lon, Latitude: &lat, Altitude: &alt}} - m, err := PrepareForIndex(doc, map[string]FieldOpts{ - "location": {Type: TypeGeopoint}, + m, err := PrepareForIndex(doc, map[string]FieldOpts{ + "location": {Type: TypeGeopoint}, + }) + Expect(err).ToNot(HaveOccurred()) + + // Original location object stays untouched (full libregraph shape). + orig, ok := m["location"].(map[string]any) + Expect(ok).To(BeTrue(), "expected location object preserved, got %T", m["location"]) + Expect(orig["longitude"]).To(Equal(lon)) + Expect(orig["latitude"]).To(Equal(lat)) + Expect(orig["altitude"]).To(Equal(alt)) + + // Sibling location_geopoint has {lat, lon} for the geo indices. + gp, ok := m["location"+GeopointSuffix].(map[string]any) + Expect(ok).To(BeTrue(), "expected location_geopoint sibling, got %T", m["location"+GeopointSuffix]) + Expect(gp["lat"]).To(Equal(lat)) + Expect(gp["lon"]).To(Equal(lon)) }) - if err != nil { - t.Fatalf("PrepareForIndex: %v", err) - } - // Original location object stays untouched (full libregraph shape). - orig, ok := m["location"].(map[string]any) - if !ok { - t.Fatalf("expected location object preserved, got %T", m["location"]) - } - if orig["longitude"] != lon || orig["latitude"] != lat || orig["altitude"] != alt { - t.Errorf("location object: %#v", orig) - } - - // Sibling location_geopoint has {lat, lon} for the geo indices. - gp, ok := m["location"+GeopointSuffix].(map[string]any) - if !ok { - t.Fatalf("expected location_geopoint sibling, got %T", m["location"+GeopointSuffix]) - } - if gp["lat"] != lat || gp["lon"] != lon { - t.Errorf("sibling: %#v", gp) - } -} - -func TestPrepareForIndexSkipsIncompleteGeopoint(t *testing.T) { - type geoDoc struct { - Location *struct { + It("skips incomplete geopoints", func() { + type geoDoc struct { + Location *struct { + Altitude *float64 `json:"altitude,omitempty"` + } `json:"location,omitempty"` + } + alt := 100.0 + doc := geoDoc{Location: &struct { Altitude *float64 `json:"altitude,omitempty"` - } `json:"location,omitempty"` - } - alt := 100.0 - doc := geoDoc{Location: &struct { - Altitude *float64 `json:"altitude,omitempty"` - }{Altitude: &alt}} + }{Altitude: &alt}} - m, err := PrepareForIndex(doc, map[string]FieldOpts{ - "location": {Type: TypeGeopoint}, + m, err := PrepareForIndex(doc, map[string]FieldOpts{ + "location": {Type: TypeGeopoint}, + }) + Expect(err).ToNot(HaveOccurred()) + // Original stays (altitude alone is still useful metadata). + Expect(m).To(HaveKey("location"), "location should still be present when only altitude is set") + // No sibling without both lon and lat. + Expect(m).ToNot(HaveKey("location"+GeopointSuffix), "no sibling expected") }) - if err != nil { - t.Fatalf("PrepareForIndex: %v", err) - } - // Original stays (altitude alone is still useful metadata). - if _, ok := m["location"]; !ok { - t.Error("location should still be present when only altitude is set") - } - // No sibling without both lon and lat. - if _, ok := m["location"+GeopointSuffix]; ok { - t.Errorf("no sibling expected, got %#v", m["location"+GeopointSuffix]) - } -} -func TestPrepareForIndexWithoutOverrideNoSibling(t *testing.T) { - type geoDoc struct { - Location *struct { + It("writes no sibling without the geopoint override", func() { + type geoDoc struct { + Location *struct { + Longitude *float64 `json:"longitude,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + } `json:"location,omitempty"` + } + lon, lat := 11.1, 49.4 + doc := geoDoc{Location: &struct { Longitude *float64 `json:"longitude,omitempty"` Latitude *float64 `json:"latitude,omitempty"` - } `json:"location,omitempty"` - } - lon, lat := 11.1, 49.4 - doc := geoDoc{Location: &struct { - Longitude *float64 `json:"longitude,omitempty"` - Latitude *float64 `json:"latitude,omitempty"` - }{Longitude: &lon, Latitude: &lat}} + }{Longitude: &lon, Latitude: &lat}} - m, err := PrepareForIndex(doc, nil) - if err != nil { - t.Fatalf("PrepareForIndex: %v", err) - } - if _, ok := m["location"+GeopointSuffix]; ok { - t.Errorf("no sibling expected without override, got %#v", m["location"+GeopointSuffix]) - } -} - -func TestPrepareForIndexHandlesNestedGeopoint(t *testing.T) { - // journey.start and journey.end - two geopoints in the same facet, - // demonstrating the dotted-path walker. - type geo struct { - Longitude *float64 `json:"longitude,omitempty"` - Latitude *float64 `json:"latitude,omitempty"` - } - type journey struct { - Start *geo `json:"start,omitempty"` - End *geo `json:"end,omitempty"` - } - type doc struct { - Journey *journey `json:"journey,omitempty"` - } - slon, slat := 11.0, 49.0 - elon, elat := 13.4, 52.5 - d := doc{Journey: &journey{ - Start: &geo{Longitude: &slon, Latitude: &slat}, - End: &geo{Longitude: &elon, Latitude: &elat}, - }} - - m, err := PrepareForIndex(d, map[string]FieldOpts{ - "journey.start": {Type: TypeGeopoint}, - "journey.end": {Type: TypeGeopoint}, + m, err := PrepareForIndex(doc, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(m).ToNot(HaveKey("location"+GeopointSuffix), "no sibling expected without override") }) - if err != nil { - t.Fatalf("PrepareForIndex: %v", err) - } - j, ok := m["journey"].(map[string]any) - if !ok { - t.Fatalf("journey not an object: %T", m["journey"]) - } - startGp, ok := j["start"+GeopointSuffix].(map[string]any) - if !ok || startGp["lat"] != slat || startGp["lon"] != slon { - t.Errorf("journey.start sibling: %#v", j["start"+GeopointSuffix]) - } - endGp, ok := j["end"+GeopointSuffix].(map[string]any) - if !ok || endGp["lat"] != elat || endGp["lon"] != elon { - t.Errorf("journey.end sibling: %#v", j["end"+GeopointSuffix]) - } -} + + It("handles nested geopoints via the dotted-path walker", func() { + // journey.start and journey.end - two geopoints in the same facet, + // demonstrating the dotted-path walker. + type geo struct { + Longitude *float64 `json:"longitude,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + } + type journey struct { + Start *geo `json:"start,omitempty"` + End *geo `json:"end,omitempty"` + } + type doc struct { + Journey *journey `json:"journey,omitempty"` + } + slon, slat := 11.0, 49.0 + elon, elat := 13.4, 52.5 + d := doc{Journey: &journey{ + Start: &geo{Longitude: &slon, Latitude: &slat}, + End: &geo{Longitude: &elon, Latitude: &elat}, + }} + + m, err := PrepareForIndex(d, map[string]FieldOpts{ + "journey.start": {Type: TypeGeopoint}, + "journey.end": {Type: TypeGeopoint}, + }) + Expect(err).ToNot(HaveOccurred()) + j, ok := m["journey"].(map[string]any) + Expect(ok).To(BeTrue(), "journey not an object: %T", m["journey"]) + startGp, ok := j["start"+GeopointSuffix].(map[string]any) + Expect(ok).To(BeTrue(), "journey.start sibling: %#v", j["start"+GeopointSuffix]) + Expect(startGp["lat"]).To(Equal(slat)) + Expect(startGp["lon"]).To(Equal(slon)) + endGp, ok := j["end"+GeopointSuffix].(map[string]any) + Expect(ok).To(BeTrue(), "journey.end sibling: %#v", j["end"+GeopointSuffix]) + Expect(endGp["lat"]).To(Equal(elat)) + Expect(endGp["lon"]).To(Equal(elon)) + }) +}) diff --git a/services/search/pkg/mapping/infer_test.go b/services/search/pkg/mapping/infer_test.go index 9ee57b656a..702fd0dc96 100644 --- a/services/search/pkg/mapping/infer_test.go +++ b/services/search/pkg/mapping/infer_test.go @@ -2,52 +2,40 @@ package mapping import ( "reflect" - "testing" "time" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "google.golang.org/protobuf/types/known/timestamppb" ) -func TestInferTypeUnsupported(t *testing.T) { - if got := inferType(reflect.TypeFor[map[string]int]()); got != "" { - t.Errorf("map: got %q, want empty", got) - } - if got := inferType(reflect.TypeFor[chan int]()); got != "" { - t.Errorf("chan: got %q, want empty", got) - } -} +var _ = Describe("inferType", func() { + It("returns empty for unsupported kinds", func() { + Expect(inferType(reflect.TypeFor[map[string]int]())).To(BeEmpty(), "map") + Expect(inferType(reflect.TypeFor[chan int]())).To(BeEmpty(), "chan") + }) -func TestInferType(t *testing.T) { - cases := []struct { - name string - in any - want string - }{ - {"string", "", TypeKeyword}, - {"*string", (*string)(nil), TypeKeyword}, - {"[]string", []string(nil), TypeKeyword}, - {"bool", false, TypeBool}, - {"int", int(0), TypeNumeric}, - {"int64", int64(0), TypeNumeric}, - {"uint64", uint64(0), TypeNumeric}, - {"float64", float64(0), TypeNumeric}, - {"time.Time", time.Time{}, TypeDatetime}, - {"*time.Time", (*time.Time)(nil), TypeDatetime}, - {"*timestamppb.Timestamp", (*timestamppb.Timestamp)(nil), TypeDatetime}, - {"struct", struct{ X int }{}, TypeObject}, - {"*struct", (*struct{ X int })(nil), TypeObject}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got := inferType(reflect.TypeOf(c.in)) - if got != c.want { - t.Fatalf("inferType(%s): got %q, want %q", c.name, got, c.want) - } - }) - } -} + DescribeTable("infers the mapping type", + func(in any, want string) { + Expect(inferType(reflect.TypeOf(in))).To(Equal(want)) + }, + Entry("string", "", TypeKeyword), + Entry("*string", (*string)(nil), TypeKeyword), + Entry("[]string", []string(nil), TypeKeyword), + Entry("bool", false, TypeBool), + Entry("int", int(0), TypeNumeric), + Entry("int64", int64(0), TypeNumeric), + Entry("uint64", uint64(0), TypeNumeric), + Entry("float64", float64(0), TypeNumeric), + Entry("time.Time", time.Time{}, TypeDatetime), + Entry("*time.Time", (*time.Time)(nil), TypeDatetime), + Entry("*timestamppb.Timestamp", (*timestamppb.Timestamp)(nil), TypeDatetime), + Entry("struct", struct{ X int }{}, TypeObject), + Entry("*struct", (*struct{ X int })(nil), TypeObject), + ) +}) -func TestResolveField(t *testing.T) { +var _ = Describe("resolveField", func() { type S struct { Exported string `json:"exp"` Renamed string `json:"renamed,omitempty"` @@ -57,69 +45,56 @@ func TestResolveField(t *testing.T) { unexported string //nolint:unused } st := reflect.TypeFor[S]() - cases := []struct { - fieldIdx int - wantName string - wantSkip bool - }{ - {0, "exp", false}, - {1, "renamed", false}, - {2, "NoTag", false}, - {3, "OmitOnly", false}, - {4, "", true}, - {5, "", true}, - } - for _, c := range cases { - fi := resolveField(st.Field(c.fieldIdx)) - if fi.Skip != c.wantSkip { - t.Errorf("field %d: skip=%v, want %v", c.fieldIdx, fi.Skip, c.wantSkip) - } - if !c.wantSkip && fi.Name != c.wantName { - t.Errorf("field %d: name=%q, want %q", c.fieldIdx, fi.Name, c.wantName) - } - } -} -func TestWalkFieldsFlattensEmbedded(t *testing.T) { - type Inner struct { - A string `json:"a"` - B int `json:"b"` - } - type Outer struct { - Inner - C bool `json:"c"` - } - var names []string - err := walkFields(reflect.TypeFor[Outer](), func(fi fieldInfo) error { - names = append(names, fi.Name) - return nil + DescribeTable("resolves the field name and skip flag", + func(fieldIdx int, wantName string, wantSkip bool) { + fi := resolveField(st.Field(fieldIdx)) + Expect(fi.Skip).To(Equal(wantSkip), "field %d skip", fieldIdx) + if !wantSkip { + Expect(fi.Name).To(Equal(wantName), "field %d name", fieldIdx) + } + }, + Entry("exported json tag", 0, "exp", false), + Entry("renamed with omitempty", 1, "renamed", false), + Entry("no tag", 2, "NoTag", false), + Entry("omitempty only", 3, "OmitOnly", false), + Entry("json:- skipped", 4, "", true), + Entry("unexported skipped", 5, "", true), + ) +}) + +var _ = Describe("walkFields", func() { + It("flattens embedded structs", func() { + type Inner struct { + A string `json:"a"` + B int `json:"b"` + } + type Outer struct { + Inner + C bool `json:"c"` + } + var names []string + err := walkFields(reflect.TypeFor[Outer](), func(fi fieldInfo) error { + names = append(names, fi.Name) + return nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(names).To(Equal([]string{"a", "b", "c"})) }) - if err != nil { - t.Fatalf("walkFields: %v", err) - } - want := []string{"a", "b", "c"} - if !reflect.DeepEqual(names, want) { - t.Fatalf("got %v, want %v", names, want) - } -} +}) -func TestStructType(t *testing.T) { +var _ = Describe("structType", func() { type S struct{ X int } - cases := []struct { - name string - in reflect.Type - wantNil bool - }{ - {"struct", reflect.TypeFor[S](), false}, - {"*struct", reflect.TypeFor[*S](), false}, - {"[]struct", reflect.TypeFor[[]S](), false}, - {"time.Time", reflect.TypeFor[time.Time](), true}, - {"string", reflect.TypeFor[string](), true}, - } - for _, c := range cases { - got := structType(c.in) - if (got == nil) != c.wantNil { - t.Errorf("%s: got %v, wantNil %v", c.name, got, c.wantNil) - } - } -} + + DescribeTable("resolves struct-ish types", + func(in reflect.Type, wantNil bool) { + got := structType(in) + Expect(got == nil).To(Equal(wantNil)) + }, + Entry("struct", reflect.TypeFor[S](), false), + Entry("*struct", reflect.TypeFor[*S](), false), + Entry("[]struct", reflect.TypeFor[[]S](), false), + Entry("time.Time", reflect.TypeFor[time.Time](), true), + Entry("string", reflect.TypeFor[string](), true), + ) +}) diff --git a/services/search/pkg/mapping/mapping_suite_test.go b/services/search/pkg/mapping/mapping_suite_test.go new file mode 100644 index 0000000000..4053109597 --- /dev/null +++ b/services/search/pkg/mapping/mapping_suite_test.go @@ -0,0 +1,13 @@ +package mapping + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMapping(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Mapping Suite") +} diff --git a/services/search/pkg/mapping/opensearch_test.go b/services/search/pkg/mapping/opensearch_test.go index de93e8d100..3ff80f20e3 100644 --- a/services/search/pkg/mapping/opensearch_test.go +++ b/services/search/pkg/mapping/opensearch_test.go @@ -2,8 +2,10 @@ package mapping import ( "reflect" - "testing" "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) type osDoc struct { @@ -18,154 +20,117 @@ type osDoc struct { } `json:"nested,omitempty"` } -func TestOpenSearchNumericTypes(t *testing.T) { - type doc struct { - A int8 `json:"a"` - B int16 `json:"b"` - C int32 `json:"c"` - D int64 `json:"d"` - E uint8 `json:"e"` - F uint64 `json:"f"` - G float32 `json:"g"` - H float64 `json:"h"` - } - props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), nil) - if err != nil { - t.Fatalf("OpenSearchBuildMapping: %v", err) - } - want := map[string]string{"a": "short", "b": "short", "c": "integer", "d": "long", "e": "short", "f": "long", "g": "float", "h": "double"} - for k, wt := range want { - if got := props[k].(map[string]any)["type"]; got != wt { - t.Errorf("%s: type = %v, want %v", k, got, wt) - } - } -} +var _ = Describe("OpenSearchBuildMapping", func() { + DescribeTable("maps numeric Go types to OpenSearch types", + func(field, wantType string) { + type doc struct { + A int8 `json:"a"` + B int16 `json:"b"` + C int32 `json:"c"` + D int64 `json:"d"` + E uint8 `json:"e"` + F uint64 `json:"f"` + G float32 `json:"g"` + H float64 `json:"h"` + } + props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), nil) + Expect(err).ToNot(HaveOccurred()) + Expect(props[field].(map[string]any)["type"]).To(Equal(wantType), "%s type", field) + }, + Entry("int8 -> short", "a", "short"), + Entry("int16 -> short", "b", "short"), + Entry("int32 -> integer", "c", "integer"), + Entry("int64 -> long", "d", "long"), + Entry("uint8 -> short", "e", "short"), + Entry("uint64 -> long", "f", "long"), + Entry("float32 -> float", "g", "float"), + Entry("float64 -> double", "h", "double"), + ) -func TestOpenSearchBuildMappingInferred(t *testing.T) { - props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil) - if err != nil { - t.Fatalf("OpenSearchBuildMapping: %v", err) - } - want := map[string]string{ - "ID": "keyword", - "Size": "long", - "Deleted": "boolean", - "CreatedAt": "date", - "Rating": "double", - } - for k, v := range want { - m, ok := props[k].(map[string]any) - if !ok { - t.Errorf("%s: missing or not a map: %#v", k, props[k]) - continue - } - if got := m["type"]; got != v { - t.Errorf("%s: type %v, want %v", k, got, v) - } - } -} + DescribeTable("infers field types", + func(field, wantType string) { + props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil) + Expect(err).ToNot(HaveOccurred()) + m, ok := props[field].(map[string]any) + Expect(ok).To(BeTrue(), "%s: missing or not a map: %#v", field, props[field]) + Expect(m["type"]).To(Equal(wantType), "%s type", field) + }, + Entry("ID -> keyword", "ID", "keyword"), + Entry("Size -> long", "Size", "long"), + Entry("Deleted -> boolean", "Deleted", "boolean"), + Entry("CreatedAt -> date", "CreatedAt", "date"), + Entry("Rating -> double", "Rating", "double"), + ) -func TestOpenSearchBuildMappingNested(t *testing.T) { - props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil) - if err != nil { - t.Fatalf("OpenSearchBuildMapping: %v", err) - } - nested, ok := props["nested"].(map[string]any) - if !ok { - t.Fatalf("nested: not a map: %#v", props["nested"]) - } - sub, ok := nested["properties"].(map[string]any) - if !ok { - t.Fatalf("nested.properties: missing: %#v", nested) - } - artist, ok := sub["artist"].(map[string]any) - if !ok { - t.Fatalf("nested.artist: %#v", sub) - } - if artist["type"] != "keyword" { - t.Errorf("nested.artist.type: %v", artist["type"]) - } - year, ok := sub["year"].(map[string]any) - if !ok { - t.Fatalf("nested.year: %#v", sub) - } - if year["type"] != "integer" { - t.Errorf("nested.year.type: %v (int32 → integer expected)", year["type"]) - } -} - -func TestOpenSearchBuildMappingOverrides(t *testing.T) { - type doc struct { - Name string `json:"Name"` - Content string `json:"Content"` - Path string `json:"Path"` - MimeType string `json:"MimeType"` - } - props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{ - "Name": {Analyzer: "lowercaseKeyword"}, - "Content": {Type: TypeFulltext}, - "Path": {Type: TypePath}, - "MimeType": {Type: TypeWildcard}, + It("maps nested structs with their sub-properties", func() { + props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil) + Expect(err).ToNot(HaveOccurred()) + nested, ok := props["nested"].(map[string]any) + Expect(ok).To(BeTrue(), "nested: not a map: %#v", props["nested"]) + sub, ok := nested["properties"].(map[string]any) + Expect(ok).To(BeTrue(), "nested.properties: missing: %#v", nested) + artist, ok := sub["artist"].(map[string]any) + Expect(ok).To(BeTrue(), "nested.artist: %#v", sub) + Expect(artist["type"]).To(Equal("keyword"), "nested.artist.type") + year, ok := sub["year"].(map[string]any) + Expect(ok).To(BeTrue(), "nested.year: %#v", sub) + Expect(year["type"]).To(Equal("integer"), "nested.year.type (int32 -> integer expected)") }) - if err != nil { - t.Fatalf("OpenSearchBuildMapping: %v", err) - } - name := props["Name"].(map[string]any) - if name["type"] != "text" || name["analyzer"] != "lowercaseKeyword" { - t.Errorf("Name: %#v", name) - } - content := props["Content"].(map[string]any) - if content["type"] != "text" || content["term_vector"] != "with_positions_offsets" { - t.Errorf("Content: %#v", content) - } - if _, ok := content["analyzer"]; ok { - t.Errorf("Content should leave analyzer unset (use OpenSearch default), got %#v", content["analyzer"]) - } - path := props["Path"].(map[string]any) - if path["type"] != "text" || path["analyzer"] != "path_hierarchy" { - t.Errorf("Path: %#v", path) - } - mime := props["MimeType"].(map[string]any) - if mime["type"] != "wildcard" { - t.Errorf("MimeType: %#v", mime) - } -} -func TestOpenSearchBuildMappingGeopoint(t *testing.T) { - type doc struct { - Location *struct { - Lon float64 `json:"longitude"` - Lat float64 `json:"latitude"` - Alt float64 `json:"altitude"` - } `json:"location,omitempty"` - } - props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{ - "location": {Type: TypeGeopoint}, - }) - if err != nil { - t.Fatalf("OpenSearchBuildMapping: %v", err) - } - // Object for libregraph-shape data retrieval. - loc, ok := props["location"].(map[string]any) - if !ok { - t.Fatalf("location: %#v", props["location"]) - } - sub, ok := loc["properties"].(map[string]any) - if !ok { - t.Fatalf("location should have numeric sub-properties, got %#v", loc) - } - for _, k := range []string{"longitude", "latitude", "altitude"} { - prop, ok := sub[k].(map[string]any) - if !ok || prop["type"] != "double" { - t.Errorf("location.%s: %#v", k, sub[k]) + It("applies field overrides", func() { + type doc struct { + Name string `json:"Name"` + Content string `json:"Content"` + Path string `json:"Path"` + MimeType string `json:"MimeType"` } - } - // Sibling geo_point for spatial queries. - gp, ok := props["location"+GeopointSuffix].(map[string]any) - if !ok { - t.Fatalf("location%s: %#v", GeopointSuffix, props["location"+GeopointSuffix]) - } - if gp["type"] != "geo_point" { - t.Errorf("location%s.type: %v", GeopointSuffix, gp["type"]) - } -} + props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{ + "Name": {Analyzer: "lowercaseKeyword"}, + "Content": {Type: TypeFulltext}, + "Path": {Type: TypePath}, + "MimeType": {Type: TypeWildcard}, + }) + Expect(err).ToNot(HaveOccurred()) + name := props["Name"].(map[string]any) + Expect(name["type"]).To(Equal("text"), "Name: %#v", name) + Expect(name["analyzer"]).To(Equal("lowercaseKeyword"), "Name: %#v", name) + content := props["Content"].(map[string]any) + Expect(content["type"]).To(Equal("text"), "Content: %#v", content) + Expect(content["term_vector"]).To(Equal("with_positions_offsets"), "Content: %#v", content) + _, ok := content["analyzer"] + Expect(ok).To(BeFalse(), "Content should leave analyzer unset (use OpenSearch default)") + path := props["Path"].(map[string]any) + Expect(path["type"]).To(Equal("text"), "Path: %#v", path) + Expect(path["analyzer"]).To(Equal("path_hierarchy"), "Path: %#v", path) + mime := props["MimeType"].(map[string]any) + Expect(mime["type"]).To(Equal("wildcard"), "MimeType: %#v", mime) + }) + + It("builds an object plus a geo_point sibling for geopoints", func() { + type doc struct { + Location *struct { + Lon float64 `json:"longitude"` + Lat float64 `json:"latitude"` + Alt float64 `json:"altitude"` + } `json:"location,omitempty"` + } + props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{ + "location": {Type: TypeGeopoint}, + }) + Expect(err).ToNot(HaveOccurred()) + // Object for libregraph-shape data retrieval. + loc, ok := props["location"].(map[string]any) + Expect(ok).To(BeTrue(), "location: %#v", props["location"]) + sub, ok := loc["properties"].(map[string]any) + Expect(ok).To(BeTrue(), "location should have numeric sub-properties, got %#v", loc) + for _, k := range []string{"longitude", "latitude", "altitude"} { + prop, ok := sub[k].(map[string]any) + Expect(ok).To(BeTrue(), "location.%s: %#v", k, sub[k]) + Expect(prop["type"]).To(Equal("double"), "location.%s: %#v", k, sub[k]) + } + // Sibling geo_point for spatial queries. + gp, ok := props["location"+GeopointSuffix].(map[string]any) + Expect(ok).To(BeTrue(), "location%s: %#v", GeopointSuffix, props["location"+GeopointSuffix]) + Expect(gp["type"]).To(Equal("geo_point"), "location%s.type", GeopointSuffix) + }) +}) diff --git a/services/search/pkg/mapping/serialize_test.go b/services/search/pkg/mapping/serialize_test.go index b352708999..18051639fa 100644 --- a/services/search/pkg/mapping/serialize_test.go +++ b/services/search/pkg/mapping/serialize_test.go @@ -1,83 +1,67 @@ package mapping import ( - "reflect" - "testing" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestPrepareForIndexError(t *testing.T) { - // a func field can't be json-marshalled -> conversions.To errors - type bad struct { - F func() `json:"f"` - } - if _, err := PrepareForIndex(bad{}, nil); err == nil { - t.Error("expected error for non-marshallable value") - } -} +var _ = Describe("PrepareForIndex serialization", func() { + It("errors for a non-marshallable value", func() { + // a func field can't be json-marshalled -> conversions.To errors + type bad struct { + F func() `json:"f"` + } + _, err := PrepareForIndex(bad{}, nil) + Expect(err).To(HaveOccurred()) + }) -func TestPrepareForIndexNil(t *testing.T) { - // a typed nil pointer marshals to null -> nil map, no error, no panic - out, err := PrepareForIndex((*struct{})(nil), nil) - if err != nil || out != nil { - t.Errorf("got (%v, %v), want (nil, nil)", out, err) - } -} + It("returns nil map for a typed nil pointer", func() { + // a typed nil pointer marshals to null -> nil map, no error, no panic + out, err := PrepareForIndex((*struct{})(nil), nil) + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(BeNil()) + }) -func TestPrepareForIndexFlattensEmbedded(t *testing.T) { - type inner struct { - Name string `json:"Name"` - Size uint64 `json:"Size"` - } - type outer struct { - inner - ID string `json:"ID"` - } - m, err := PrepareForIndex(outer{inner: inner{Name: "a", Size: 7}, ID: "x"}, nil) - if err != nil { - t.Fatalf("PrepareForIndex: %v", err) - } - want := map[string]any{"Name": "a", "Size": float64(7), "ID": "x"} - if !reflect.DeepEqual(m, want) { - t.Fatalf("got %#v, want %#v", m, want) - } -} + It("flattens embedded structs", func() { + type inner struct { + Name string `json:"Name"` + Size uint64 `json:"Size"` + } + type outer struct { + inner + ID string `json:"ID"` + } + m, err := PrepareForIndex(outer{inner: inner{Name: "a", Size: 7}, ID: "x"}, nil) + Expect(err).ToNot(HaveOccurred()) + want := map[string]any{"Name": "a", "Size": float64(7), "ID": "x"} + Expect(m).To(Equal(want)) + }) -func TestPrepareForIndexOmitsNilWithOmitempty(t *testing.T) { - type facet struct { - Artist string `json:"artist"` - } - type doc struct { - Name string `json:"Name"` - Audio *facet `json:"audio,omitempty"` - } - m, err := PrepareForIndex(doc{Name: "n"}, nil) - if err != nil { - t.Fatalf("PrepareForIndex: %v", err) - } - if _, ok := m["audio"]; ok { - t.Errorf("audio should be omitted when nil: %#v", m) - } - if m["Name"] != "n" { - t.Errorf("Name: %v", m["Name"]) - } -} + It("omits nil fields tagged omitempty", func() { + type facet struct { + Artist string `json:"artist"` + } + type doc struct { + Name string `json:"Name"` + Audio *facet `json:"audio,omitempty"` + } + m, err := PrepareForIndex(doc{Name: "n"}, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(m).ToNot(HaveKey("audio")) + Expect(m["Name"]).To(Equal("n")) + }) -func TestPrepareForIndexIncludesNestedWhenSet(t *testing.T) { - type facet struct { - Artist string `json:"artist"` - } - type doc struct { - Audio *facet `json:"audio,omitempty"` - } - m, err := PrepareForIndex(doc{Audio: &facet{Artist: "A"}}, nil) - if err != nil { - t.Fatalf("PrepareForIndex: %v", err) - } - nested, ok := m["audio"].(map[string]any) - if !ok { - t.Fatalf("audio should be a nested map: %#v", m["audio"]) - } - if nested["artist"] != "A" { - t.Errorf("audio.artist: %v", nested["artist"]) - } -} + It("includes nested facets when set", func() { + type facet struct { + Artist string `json:"artist"` + } + type doc struct { + Audio *facet `json:"audio,omitempty"` + } + m, err := PrepareForIndex(doc{Audio: &facet{Artist: "A"}}, nil) + Expect(err).ToNot(HaveOccurred()) + nested, ok := m["audio"].(map[string]any) + Expect(ok).To(BeTrue(), "audio should be a nested map: %#v", m["audio"]) + Expect(nested["artist"]).To(Equal("A")) + }) +}) diff --git a/services/search/pkg/mapping/validate_test.go b/services/search/pkg/mapping/validate_test.go index 2d8500c2b8..278bb3820f 100644 --- a/services/search/pkg/mapping/validate_test.go +++ b/services/search/pkg/mapping/validate_test.go @@ -2,8 +2,9 @@ package mapping import ( "reflect" - "strings" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) type inner struct { @@ -19,33 +20,28 @@ type sample struct { } `json:"location,omitempty"` } -func TestValidateAccepts(t *testing.T) { - err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{ - "Name": {Analyzer: "lowercaseKeyword"}, - "audio": {Type: TypeObject}, - "audio.artist": {Analyzer: "lowercaseKeyword"}, - "location": {Type: TypeGeopoint}, +var _ = Describe("Validate", func() { + It("accepts known override keys", func() { + err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{ + "Name": {Analyzer: "lowercaseKeyword"}, + "audio": {Type: TypeObject}, + "audio.artist": {Analyzer: "lowercaseKeyword"}, + "location": {Type: TypeGeopoint}, + }) + Expect(err).ToNot(HaveOccurred()) }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} -func TestValidateRejectsUnknown(t *testing.T) { - err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{ - "nope": {}, - "audio.zzz": {}, + It("rejects unknown override keys", func() { + err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{ + "nope": {}, + "audio.zzz": {}, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("nope")) + Expect(err.Error()).To(ContainSubstring("audio.zzz")) }) - if err == nil { - t.Fatalf("expected error") - } - if !strings.Contains(err.Error(), "nope") || !strings.Contains(err.Error(), "audio.zzz") { - t.Fatalf("error missing keys: %v", err) - } -} -func TestValidateEmpty(t *testing.T) { - if err := Validate(reflect.TypeFor[sample](), nil); err != nil { - t.Fatalf("empty overrides should pass: %v", err) - } -} + It("accepts empty overrides", func() { + Expect(Validate(reflect.TypeFor[sample](), nil)).To(Succeed()) + }) +})