test(search): port fieldindex, mimetype and normalize tests to ginkgo

This commit is contained in:
Dominik Schmidt committed 2026-08-18 17:12:32 +02:00
1 parent 65712716a8
commit f40544de04
5 files changed
+270 -235

No files matched your search

+66 -63
View File
@@ -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
})
})
@@ -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")
}
@@ -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",
}))
})
})
+88 -82
View File
@@ -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"},
}))
})
})
@@ -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")
}