diff --git a/services/search/pkg/mapping/fieldindex_test.go b/services/search/pkg/mapping/fieldindex_test.go index 56e6b0119b..a5c0fb0f5b 100644 --- a/services/search/pkg/mapping/fieldindex_test.go +++ b/services/search/pkg/mapping/fieldindex_test.go @@ -3,15 +3,15 @@ package mapping_test import ( "reflect" "strings" - "testing" - "github.com/stretchr/testify/require" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "github.com/opencloud-eu/opencloud/services/search/pkg/mapping" "github.com/opencloud-eu/opencloud/services/search/pkg/search" ) -func resourceFieldIndex(testing.TB) map[string]string { +func resourceFieldIndex() map[string]string { return mapping.FieldNameIndex( reflect.TypeFor[search.Resource](), search.Resource{}.SearchFieldOverrides(), @@ -26,58 +26,6 @@ func resolve(idx map[string]string, key string) string { return key } -func TestFieldNameIndex_TopLevelCaseInsensitive(t *testing.T) { - idx := resourceFieldIndex(t) - for in, want := range map[string]string{ - "rootid": "RootID", "ROOTID": "RootID", "RootID": "RootID", - "name": "Name", "NAME": "Name", - "mimetype": "MimeType", "MimeType": "MimeType", - "tags": "Tags", "favorites": "Favorites", - "mtime": "Mtime", "parentid": "ParentID", "id": "ID", - } { - require.Equalf(t, want, resolve(idx, in), "resolve(%q)", in) - } -} - -// Facet sub-fields are lowerCamelCase in the index (from libregraph json tags); -// the derived index resolves them case-insensitively. -func TestFieldNameIndex_FacetsCaseInsensitive(t *testing.T) { - idx := resourceFieldIndex(t) - for in, want := range map[string]string{ - // case-insensitive: same field, different casings - "photo.cameramake": "photo.cameraMake", - "photo.CAMERAMAKE": "photo.cameraMake", - // a representative sub-field across each facet - "photo.takendatetime": "photo.takenDateTime", - "audio.artist": "audio.artist", - "audio.albumartist": "audio.albumArtist", - "image.width": "image.width", - "location.latitude": "location.latitude", - } { - require.Equalf(t, want, resolve(idx, in), "resolve(%q)", in) - } -} - -func TestFieldNameIndex_UnknownPassesThrough(t *testing.T) { - idx := resourceFieldIndex(t) - require.Equal(t, "nope.field", resolve(idx, "nope.field")) - require.Equal(t, "custom", resolve(idx, "custom")) -} - -// All top-level fields are covered from one derived source, so both backends -// resolve them the same way. -func TestFieldNameIndex_CoversAllTopLevelFields(t *testing.T) { - idx := resourceFieldIndex(t) - for in, want := range map[string]string{ - "rootid": "RootID", "path": "Path", "id": "ID", "name": "Name", - "size": "Size", "mtime": "Mtime", "type": "Type", - "content": "Content", "hidden": "Hidden", "tags": "Tags", - "favorites": "Favorites", - } { - require.Equalf(t, want, resolve(idx, in), "derived should cover %q", in) - } -} - // NestInner is embedded with a json tag below, so it must nest, not flatten. type NestInner struct { A string `json:"A"` @@ -88,11 +36,66 @@ type taggedOuter struct { Top string `json:"top"` } -// A json-tagged embedded struct nests under its tag in the derived index too -// (walkFields must match encoding/json), so its fields are "inner.A", not "A". -func TestFieldNameIndex_TaggedEmbeddedNests(t *testing.T) { - idx := mapping.FieldNameIndex(reflect.TypeFor[taggedOuter](), nil) - require.Equal(t, "inner.A", resolve(idx, "inner.a")) // nested under the tag - require.Equal(t, "top", resolve(idx, "top")) - require.Equal(t, "a", resolve(idx, "a")) // not flattened: bare "a" is not a key -} +var _ = Describe("FieldNameIndex", func() { + It("resolves top-level fields case-insensitively", func() { + idx := resourceFieldIndex() + for in, want := range map[string]string{ + "rootid": "RootID", "ROOTID": "RootID", "RootID": "RootID", + "name": "Name", "NAME": "Name", + "mimetype": "MimeType", "MimeType": "MimeType", + "tags": "Tags", "favorites": "Favorites", + "mtime": "Mtime", "parentid": "ParentID", "id": "ID", + } { + Expect(resolve(idx, in)).To(Equal(want), "resolve(%q)", in) + } + }) + + // Facet sub-fields are lowerCamelCase in the index (from libregraph json + // tags); the derived index resolves them case-insensitively. + It("resolves facet sub-fields case-insensitively", func() { + idx := resourceFieldIndex() + for in, want := range map[string]string{ + // case-insensitive: same field, different casings + "photo.cameramake": "photo.cameraMake", + "photo.CAMERAMAKE": "photo.cameraMake", + // a representative sub-field across each facet + "photo.takendatetime": "photo.takenDateTime", + "audio.artist": "audio.artist", + "audio.albumartist": "audio.albumArtist", + "image.width": "image.width", + "location.latitude": "location.latitude", + } { + Expect(resolve(idx, in)).To(Equal(want), "resolve(%q)", in) + } + }) + + It("passes unknown keys through unchanged", func() { + idx := resourceFieldIndex() + Expect(resolve(idx, "nope.field")).To(Equal("nope.field")) + Expect(resolve(idx, "custom")).To(Equal("custom")) + }) + + // All top-level fields are covered from one derived source, so both + // backends resolve them the same way. + It("covers all top-level fields", func() { + idx := resourceFieldIndex() + for in, want := range map[string]string{ + "rootid": "RootID", "path": "Path", "id": "ID", "name": "Name", + "size": "Size", "mtime": "Mtime", "type": "Type", + "content": "Content", "hidden": "Hidden", "tags": "Tags", + "favorites": "Favorites", + } { + Expect(resolve(idx, in)).To(Equal(want), "derived should cover %q", in) + } + }) + + // A json-tagged embedded struct nests under its tag in the derived index + // too (walkFields must match encoding/json), so its fields are "inner.A", + // not "A". + It("nests json-tagged embedded structs", func() { + idx := mapping.FieldNameIndex(reflect.TypeFor[taggedOuter](), nil) + Expect(resolve(idx, "inner.a")).To(Equal("inner.A")) // nested under the tag + Expect(resolve(idx, "top")).To(Equal("top")) + Expect(resolve(idx, "a")).To(Equal("a")) // not flattened: bare "a" is not a key + }) +}) diff --git a/services/search/pkg/query/mimetype/mimetype_suite_test.go b/services/search/pkg/query/mimetype/mimetype_suite_test.go new file mode 100644 index 0000000000..321ff2e8d9 --- /dev/null +++ b/services/search/pkg/query/mimetype/mimetype_suite_test.go @@ -0,0 +1,13 @@ +package mimetype_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMimetype(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Mimetype Suite") +} diff --git a/services/search/pkg/query/mimetype/mimetype_test.go b/services/search/pkg/query/mimetype/mimetype_test.go index c9d9a7cf6c..51fe24e357 100644 --- a/services/search/pkg/query/mimetype/mimetype_test.go +++ b/services/search/pkg/query/mimetype/mimetype_test.go @@ -1,9 +1,8 @@ package mimetype_test import ( - "testing" - - "github.com/stretchr/testify/require" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "github.com/opencloud-eu/opencloud/pkg/ast" "github.com/opencloud-eu/opencloud/services/search/pkg/query/mimetype" @@ -12,93 +11,6 @@ import ( // This is the single place the mediatype -> MimeType mapping is tested. The // query pipeline consumes Expand via query.Normalize and must NOT re-test it. -func TestExpand_onlyTriggersOnMediatype(t *testing.T) { - require.Nil(t, mimetype.Expand("Name", "document")) - require.Nil(t, mimetype.Expand("MimeType", "file")) // the real field name is not the trigger - require.Nil(t, mimetype.Expand("Tags", "file")) -} - -func TestExpand_keyIsCaseInsensitive(t *testing.T) { - require.NotNil(t, mimetype.Expand("MediaType", "file")) -} - -func TestExpand_valueIsCaseInsensitive(t *testing.T) { - // a category matches regardless of case - require.Equal(t, []ast.Node{ - &ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"}, - }, mimetype.Expand("mediatype", "Folder")) - // a literal MIME type is lowercased too (MIME types are case-insensitive) - require.Equal(t, []ast.Node{ - &ast.StringNode{Key: "MimeType", Value: "image/svg+xml"}, - }, mimetype.Expand("mediatype", "Image/SVG+XML")) -} - -// A non-category value is a literal MIME type and targets the MimeType field. -func TestExpand_literalValuePassesThroughToMimeType(t *testing.T) { - require.Equal(t, []ast.Node{ - &ast.StringNode{Key: "MimeType", Value: "application/pdf"}, - }, mimetype.Expand("mediatype", "application/pdf")) - - require.Equal(t, []ast.Node{ - &ast.StringNode{Key: "MimeType", Value: "image/jpeg"}, - }, mimetype.Expand("mediatype", "image/jpeg")) -} - -func TestExpand_fileIsNotAFolder(t *testing.T) { - // grouped so the negation stays atomic next to an operator. - require.Equal(t, []ast.Node{ - &ast.GroupNode{Nodes: []ast.Node{ - &ast.OperatorNode{Value: "NOT"}, - &ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"}, - }}, - }, mimetype.Expand("mediatype", "file")) -} - -func TestExpand_folderIsASingleTerm(t *testing.T) { - require.Equal(t, []ast.Node{ - &ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"}, - }, mimetype.Expand("mediatype", "folder")) -} - -func TestExpand_wildcardCategories(t *testing.T) { - for value, mime := range map[string]string{ - "image": "image/*", "video": "video/*", "audio": "audio/*", "pdf": "application/pdf", - } { - require.Equal(t, []ast.Node{ - &ast.StringNode{Key: "MimeType", Value: mime}, - }, mimetype.Expand("mediatype", value), value) - } -} - -func TestExpand_documentGroup(t *testing.T) { - got := mimetype.Expand("mediatype", "document") - require.Len(t, got, 1) - group, ok := got[0].(*ast.GroupNode) - require.True(t, ok) - require.Equal(t, mimeValues(group), []string{ - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.openxmlformats-officedocument.wordprocessingml.form", - "application/vnd.oasis.opendocument.text", - "text/plain", - "text/markdown", - "application/rtf", - "application/vnd.apple.pages", - }) -} - -// spreadsheet asserts the exact MIME set, in order, with no duplicate entry. -func TestExpand_spreadsheet(t *testing.T) { - group := mimetype.Expand("mediatype", "spreadsheet")[0].(*ast.GroupNode) - require.Equal(t, mimeValues(group), []string{ - "application/vnd.ms-excel", - "application/vnd.oasis.opendocument.spreadsheet", - "text/csv", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "application/vnd.apple.numbers", - }) -} - // mimeValues extracts the StringNode values from an OR group, dropping operators. func mimeValues(group *ast.GroupNode) []string { var out []string @@ -109,3 +21,91 @@ func mimeValues(group *ast.GroupNode) []string { } return out } + +var _ = Describe("Expand", func() { + It("only triggers on the mediatype key", func() { + Expect(mimetype.Expand("Name", "document")).To(BeNil()) + Expect(mimetype.Expand("MimeType", "file")).To(BeNil()) // the real field name is not the trigger + Expect(mimetype.Expand("Tags", "file")).To(BeNil()) + }) + + It("matches the key case-insensitively", func() { + Expect(mimetype.Expand("MediaType", "file")).ToNot(BeNil()) + }) + + It("matches the value case-insensitively", func() { + // a category matches regardless of case + Expect(mimetype.Expand("mediatype", "Folder")).To(Equal([]ast.Node{ + &ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"}, + })) + // a literal MIME type is lowercased too (MIME types are case-insensitive) + Expect(mimetype.Expand("mediatype", "Image/SVG+XML")).To(Equal([]ast.Node{ + &ast.StringNode{Key: "MimeType", Value: "image/svg+xml"}, + })) + }) + + // A non-category value is a literal MIME type and targets the MimeType field. + It("passes literal values through to MimeType", func() { + Expect(mimetype.Expand("mediatype", "application/pdf")).To(Equal([]ast.Node{ + &ast.StringNode{Key: "MimeType", Value: "application/pdf"}, + })) + Expect(mimetype.Expand("mediatype", "image/jpeg")).To(Equal([]ast.Node{ + &ast.StringNode{Key: "MimeType", Value: "image/jpeg"}, + })) + }) + + It("expands file to not-a-folder", func() { + // grouped so the negation stays atomic next to an operator. + Expect(mimetype.Expand("mediatype", "file")).To(Equal([]ast.Node{ + &ast.GroupNode{Nodes: []ast.Node{ + &ast.OperatorNode{Value: "NOT"}, + &ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"}, + }}, + })) + }) + + It("expands folder to a single term", func() { + Expect(mimetype.Expand("mediatype", "folder")).To(Equal([]ast.Node{ + &ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"}, + })) + }) + + It("expands wildcard categories", func() { + for value, mime := range map[string]string{ + "image": "image/*", "video": "video/*", "audio": "audio/*", "pdf": "application/pdf", + } { + Expect(mimetype.Expand("mediatype", value)).To(Equal([]ast.Node{ + &ast.StringNode{Key: "MimeType", Value: mime}, + }), value) + } + }) + + It("expands the document group", func() { + got := mimetype.Expand("mediatype", "document") + Expect(got).To(HaveLen(1)) + group, ok := got[0].(*ast.GroupNode) + Expect(ok).To(BeTrue()) + Expect(mimeValues(group)).To(Equal([]string{ + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.form", + "application/vnd.oasis.opendocument.text", + "text/plain", + "text/markdown", + "application/rtf", + "application/vnd.apple.pages", + })) + }) + + // spreadsheet asserts the exact MIME set, in order, with no duplicate entry. + It("expands the spreadsheet group", func() { + group := mimetype.Expand("mediatype", "spreadsheet")[0].(*ast.GroupNode) + Expect(mimeValues(group)).To(Equal([]string{ + "application/vnd.ms-excel", + "application/vnd.oasis.opendocument.spreadsheet", + "text/csv", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.apple.numbers", + })) + }) +}) diff --git a/services/search/pkg/query/normalize_test.go b/services/search/pkg/query/normalize_test.go index 6b97532719..def14ec135 100644 --- a/services/search/pkg/query/normalize_test.go +++ b/services/search/pkg/query/normalize_test.go @@ -1,9 +1,8 @@ package query_test import ( - "testing" - - "github.com/stretchr/testify/require" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "github.com/opencloud-eu/opencloud/pkg/ast" "github.com/opencloud-eu/opencloud/services/search/pkg/query" @@ -18,86 +17,93 @@ func norm(nodes ...ast.Node) []ast.Node { return query.Normalize(&ast.Ast{Nodes: nodes}, query.ResolveField).Nodes } -func TestResolveField(t *testing.T) { - require.Equal(t, "Name", query.ResolveField("")) // empty -> free-text default - require.Equal(t, "Name", query.ResolveField("NAME")) // canonical, case-insensitive key match - require.Equal(t, "Tags", query.ResolveField("tag")) // singular alias - require.Equal(t, "MimeType", query.ResolveField("mimetype")) // real field - require.Equal(t, "photo.cameraMake", query.ResolveField("photo.CAMERAMAKE")) // facet, case-insensitive key match - require.Equal(t, "unknown.field", query.ResolveField("unknown.field")) // unknown key: unchanged, becomes a dead query -} +var _ = Describe("ResolveField", func() { + It("resolves keys to canonical field names", func() { + Expect(query.ResolveField("")).To(Equal("Name")) // empty -> free-text default + Expect(query.ResolveField("NAME")).To(Equal("Name")) // canonical, case-insensitive key match + Expect(query.ResolveField("tag")).To(Equal("Tags")) // singular alias + Expect(query.ResolveField("mimetype")).To(Equal("MimeType")) // real field + Expect(query.ResolveField("photo.CAMERAMAKE")).To(Equal("photo.cameraMake")) // facet, case-insensitive key match + Expect(query.ResolveField("unknown.field")).To(Equal("unknown.field")) // unknown key: unchanged, becomes a dead query + }) +}) -func TestFieldIsCaseInsensitive(t *testing.T) { - // The four CaseInsensitive override fields (resolved canonical names). - for _, f := range []string{"Name", "Path", "Tags", "Favorites"} { - require.True(t, query.FieldIsCaseInsensitive(f), f) - } - // Case-preserved / non-keyword fields are not. - for _, f := range []string{"MimeType", "ID", "Content", "unknown"} { - require.False(t, query.FieldIsCaseInsensitive(f), f) - } -} +var _ = Describe("FieldIsCaseInsensitive", func() { + It("reports the CaseInsensitive override fields", func() { + // The four CaseInsensitive override fields (resolved canonical names). + for _, f := range []string{"Name", "Path", "Tags", "Favorites"} { + Expect(query.FieldIsCaseInsensitive(f)).To(BeTrue(), f) + } + // Case-preserved / non-keyword fields are not. + for _, f := range []string{"MimeType", "ID", "Content", "unknown"} { + Expect(query.FieldIsCaseInsensitive(f)).To(BeFalse(), f) + } + }) +}) -func TestNormalize_ResolvesFieldsAndExpandsMediatype(t *testing.T) { - got := norm( - &ast.StringNode{Key: "", Value: "free"}, - &ast.OperatorNode{Value: "AND"}, - &ast.StringNode{Key: "TAG", Value: "x"}, - &ast.OperatorNode{Value: "AND"}, - &ast.StringNode{Key: "photo.cameramake", Value: "canon"}, - &ast.OperatorNode{Value: "AND"}, - &ast.StringNode{Key: "mediatype", Value: "file"}, - ) - require.Equal(t, []ast.Node{ - &ast.StringNode{Key: "Name", Value: "free", CaseInsensitive: true}, - &ast.OperatorNode{Value: "AND"}, - &ast.StringNode{Key: "Tags", Value: "x", CaseInsensitive: true}, - &ast.OperatorNode{Value: "AND"}, - &ast.StringNode{Key: "photo.cameraMake", Value: "canon"}, - &ast.OperatorNode{Value: "AND"}, - &ast.GroupNode{Nodes: []ast.Node{ - &ast.OperatorNode{Value: "NOT"}, - &ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"}, - }}, - }, got) -} +var _ = Describe("Normalize", func() { + It("resolves fields and expands mediatype", func() { + got := norm( + &ast.StringNode{Key: "", Value: "free"}, + &ast.OperatorNode{Value: "AND"}, + &ast.StringNode{Key: "TAG", Value: "x"}, + &ast.OperatorNode{Value: "AND"}, + &ast.StringNode{Key: "photo.cameramake", Value: "canon"}, + &ast.OperatorNode{Value: "AND"}, + &ast.StringNode{Key: "mediatype", Value: "file"}, + ) + Expect(got).To(Equal([]ast.Node{ + &ast.StringNode{Key: "Name", Value: "free", CaseInsensitive: true}, + &ast.OperatorNode{Value: "AND"}, + &ast.StringNode{Key: "Tags", Value: "x", CaseInsensitive: true}, + &ast.OperatorNode{Value: "AND"}, + &ast.StringNode{Key: "photo.cameraMake", Value: "canon"}, + &ast.OperatorNode{Value: "AND"}, + &ast.GroupNode{Nodes: []ast.Node{ + &ast.OperatorNode{Value: "NOT"}, + &ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"}, + }}, + })) + }) -// A bare restriction inside a named group inherits the group key; a keyed child -// keeps its own key; a bare restriction in an unnamed group falls back to Name. -func TestNormalize_GroupKeyDefaulting(t *testing.T) { - got := norm( - &ast.GroupNode{Key: "author", Nodes: []ast.Node{ - &ast.StringNode{Value: "b"}, - &ast.OperatorNode{Value: "OR"}, - &ast.StringNode{Key: "name", Value: "d"}, - }}, - &ast.OperatorNode{Value: "AND"}, - &ast.GroupNode{Nodes: []ast.Node{ - &ast.StringNode{Value: "e"}, - }}, - ) - require.Equal(t, []ast.Node{ - &ast.GroupNode{Key: "author", Nodes: []ast.Node{ - &ast.StringNode{Key: "author", Value: "b"}, - &ast.OperatorNode{Value: "OR"}, - &ast.StringNode{Key: "Name", Value: "d", CaseInsensitive: true}, - }}, - &ast.OperatorNode{Value: "AND"}, - &ast.GroupNode{Nodes: []ast.Node{ - &ast.StringNode{Key: "Name", Value: "e", CaseInsensitive: true}, - }}, - }, got) -} + // A bare restriction inside a named group inherits the group key; a keyed + // child keeps its own key; a bare restriction in an unnamed group falls + // back to Name. + It("defaults group keys", func() { + got := norm( + &ast.GroupNode{Key: "author", Nodes: []ast.Node{ + &ast.StringNode{Value: "b"}, + &ast.OperatorNode{Value: "OR"}, + &ast.StringNode{Key: "name", Value: "d"}, + }}, + &ast.OperatorNode{Value: "AND"}, + &ast.GroupNode{Nodes: []ast.Node{ + &ast.StringNode{Value: "e"}, + }}, + ) + Expect(got).To(Equal([]ast.Node{ + &ast.GroupNode{Key: "author", Nodes: []ast.Node{ + &ast.StringNode{Key: "author", Value: "b"}, + &ast.OperatorNode{Value: "OR"}, + &ast.StringNode{Key: "Name", Value: "d", CaseInsensitive: true}, + }}, + &ast.OperatorNode{Value: "AND"}, + &ast.GroupNode{Nodes: []ast.Node{ + &ast.StringNode{Key: "Name", Value: "e", CaseInsensitive: true}, + }}, + })) + }) -func TestNormalize_ConvertsValueNodesToPointers(t *testing.T) { - got := norm( - ast.StringNode{Key: "name", Value: "x"}, - ast.OperatorNode{Value: "AND"}, - ast.DateTimeNode{Key: "mtime"}, - ) - require.Equal(t, []ast.Node{ - &ast.StringNode{Key: "Name", Value: "x", CaseInsensitive: true}, - &ast.OperatorNode{Value: "AND"}, - &ast.DateTimeNode{Key: "Mtime"}, - }, got) -} + It("converts value nodes to pointers", func() { + got := norm( + ast.StringNode{Key: "name", Value: "x"}, + ast.OperatorNode{Value: "AND"}, + ast.DateTimeNode{Key: "mtime"}, + ) + Expect(got).To(Equal([]ast.Node{ + &ast.StringNode{Key: "Name", Value: "x", CaseInsensitive: true}, + &ast.OperatorNode{Value: "AND"}, + &ast.DateTimeNode{Key: "Mtime"}, + })) + }) +}) diff --git a/services/search/pkg/query/query_suite_test.go b/services/search/pkg/query/query_suite_test.go new file mode 100644 index 0000000000..9d9ddf7487 --- /dev/null +++ b/services/search/pkg/query/query_suite_test.go @@ -0,0 +1,13 @@ +package query_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestQuery(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Query Suite") +}