Merge pull request #3418 from opencloud-eu/test/search-parity-suite

test(search): engine parity suite for bleve and opensearch
This commit is contained in:
Florian Schade authored and GitHub committed 2026-08-31 10:22:25 +02:00
commit ffbfe171c3
61 files changed
+3330 -1483

No files matched your search

+2
View File
@@ -18,6 +18,8 @@ exclude_paths:
- 'deployments/**'
- "release-config.ts"
- 'tests/acceptance/expected-failures-*.md'
# written by the search engine parity suite, table rows are as wide as they are
- 'services/search/pkg/parity/README.md'
- 'tests/acceptance/bootstrap/**'
- 'tests/acceptance/TestHelpers/**'
- 'tests/acceptance/scripts/run.sh'
@@ -3,6 +3,7 @@ package opensearchtest
import (
"context"
"fmt"
"net/http"
"os"
"slices"
"strings"
@@ -18,7 +19,9 @@ import (
)
const (
openSearchImage = "opensearchproject/opensearch:2"
openSearchImage = "opensearchproject/opensearch:2"
openSearchPort = "9200"
openSearchStartupTimeout = 3 * time.Minute
)
func SetupTests(ctx context.Context) (*config.Config, func(), error) {
@@ -62,6 +65,14 @@ func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func
return func() {}, nil
}
keepContainer := os.Getenv("KEEP_TEST_CONTAINER") == "true"
if keepContainer {
// the reaper would take the kept container down with the session
if err := os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true"); err != nil {
return nil, fmt.Errorf("failed to disable the testcontainers reaper: %w", err)
}
}
containerName := fmt.Sprintf("opencloud/test/%s", openSearchImage)
containerName = strings.Replace(containerName, "/", "__", -1)
containerName = strings.Replace(containerName, ":", "_", -1)
@@ -72,9 +83,21 @@ func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func
opensearch.WithPassword(cfg.Engine.OpenSearch.Client.Password),
testcontainers.WithName(containerName),
testcontainers.WithReuseByName(containerName),
// test indexes are tiny; don't let a full host disk trip the flood-stage
// create-index / read-only blocks mid-run
testcontainers.WithEnv(map[string]string{
"cluster.routing.allocation.disk.threshold_enabled": "false",
}),
// a health probe answers at once on a reused container, a log wait
// would sit out the log timeout on it; a cold boot takes well over the
// previous 5s
testcontainers.WithWaitStrategy(
wait.ForLog("ML configuration initialized successfully").
WithStartupTimeout(5*time.Second),
wait.ForHTTP("/_cluster/health?wait_for_status=yellow&timeout=1s").
WithPort(openSearchPort).
WithTLS(false).
WithBasicAuth(cfg.Engine.OpenSearch.Client.Username, cfg.Engine.OpenSearch.Client.Password).
WithStatusCodeMatcher(func(status int) bool { return status == http.StatusOK }).
WithStartupTimeout(openSearchStartupTimeout),
),
)
if err != nil {
@@ -91,6 +114,13 @@ func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func
cfg.Engine.OpenSearch.Client.Addresses = []string{address}
return func() {
// KEEP_TEST_CONTAINER=true leaves the container up for the next run,
// which picks it up again by name instead of booting a fresh one
if keepContainer {
_, _ = fmt.Fprintf(os.Stderr, "keeping OpenSearch container %s\n", containerName)
return
}
err := container.Terminate(ctx)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to terminate OpenSearch container: %v\n", err)
-808
View File
@@ -1,808 +0,0 @@
package bleve_test
import (
"context"
"fmt"
bleveSearch "github.com/blevesearch/bleve/v2"
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/opencloud/pkg/log"
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
bleveQuery "github.com/opencloud-eu/opencloud/services/search/pkg/query/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func hiddenByID(idx bleveSearch.Index, id string) bool {
GinkgoHelper()
req := bleveSearch.NewSearchRequest(bleveSearch.NewDocIDQuery([]string{id}))
req.Fields = []string{"Hidden"}
res, err := idx.Search(req)
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(HaveLen(1), "no record for %s", id)
hidden, _ := res.Hits[0].Fields["Hidden"].(bool)
return hidden
}
var _ = Describe("Bleve", func() {
var (
eng *bleve.Backend
idx bleveSearch.Index
doSearch = func(id string, query, path string) (*searchsvc.SearchIndexResponse, error) {
rID, err := storagespace.ParseID(id)
if err != nil {
return nil, err
}
return eng.Search(context.Background(), &searchsvc.SearchIndexRequest{
Query: query,
Ref: &searchmsg.Reference{
ResourceId: &searchmsg.ResourceID{
StorageId: rID.StorageId,
SpaceId: rID.SpaceId,
OpaqueId: rID.OpaqueId,
},
Path: path,
},
})
}
assertDocCount = func(id string, query string, expectedCount int) []*searchmsg.Match {
res, err := doSearch(id, query, "")
ExpectWithOffset(1, err).ToNot(HaveOccurred())
ExpectWithOffset(1, len(res.Matches)).To(Equal(expectedCount), "query returned unexpected number of results: "+query)
return res.Matches
}
rootResource search.Resource
parentResource search.Resource
childResource search.Resource
childResource2 search.Resource
)
BeforeEach(func() {
mapping, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
idx, err = bleveSearch.NewMemOnly(mapping)
Expect(err).ToNot(HaveOccurred())
eng = bleve.NewBackend(idx, bleveQuery.DefaultCreator, log.Logger{})
Expect(err).ToNot(HaveOccurred())
rootResource = search.Resource{
ID: "1$2!2",
RootID: "1$2!2",
Path: ".",
Document: content.Document{},
}
parentResource = search.Resource{
ID: "1$2!3",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./parent d!r",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER),
Document: content.Document{Name: "parent d!r"},
}
childResource = search.Resource{
ID: "1$2!4",
ParentID: parentResource.ID,
RootID: rootResource.ID,
Path: "./parent d!r/child.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "child.pdf"},
}
childResource2 = search.Resource{
ID: "1$2!5",
ParentID: parentResource.ID,
RootID: rootResource.ID,
Path: "./parent d!r/child2.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "child2.pdf"},
}
})
Describe("PurgeSpace", func() {
It("takes every record of that space out of the index", func() {
otherSpace := search.Resource{
ID: "1$9!9",
RootID: "1$9!9",
Path: ".",
Document: content.Document{Name: "other"},
}
for _, resource := range []search.Resource{rootResource, parentResource, childResource, otherSpace} {
Expect(eng.Upsert(resource.ID, resource)).To(Succeed())
}
Expect(eng.PurgeSpace(rootResource.RootID)).To(Succeed())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)), "only the records of that space are gone")
})
It("takes a space out that holds more records than one round", func() {
otherSpace := search.Resource{
ID: "1$9!9",
RootID: "1$9!9",
Path: ".",
Document: content.Document{Name: "other"},
}
Expect(eng.Upsert(otherSpace.ID, otherSpace)).To(Succeed())
for i := range 120 {
resource := search.Resource{
ID: fmt.Sprintf("%s!file-%d", rootResource.RootID, i),
RootID: rootResource.RootID,
Path: fmt.Sprintf("./file-%d", i),
Document: content.Document{Name: fmt.Sprintf("file-%d", i)},
}
Expect(eng.Upsert(resource.ID, resource)).To(Succeed())
}
Expect(eng.PurgeSpace(rootResource.RootID)).To(Succeed())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)), "only the record of the other space is left")
})
})
Describe("New", func() {
It("returns a new index instance", func() {
b := bleve.NewBackend(idx, bleveQuery.DefaultCreator, log.Logger{})
Expect(b).ToNot(BeNil())
})
})
Describe("Search", func() {
Context("by other fields than filename", func() {
It("finds files by tags", func() {
parentResource.Document.Tags = []string{"foo", "bar"}
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Tags:foo", 1)
assertDocCount(rootResource.ID, "Tags:bar", 1)
assertDocCount(rootResource.ID, "Tags:foo Tags:bar", 1)
assertDocCount(rootResource.ID, "Tags:foo Tags:bar Tags:baz", 1)
assertDocCount(rootResource.ID, "Tags:foo Tags:bar Tags:baz", 1)
assertDocCount(rootResource.ID, "Tags:baz", 0)
})
It("finds files by size", func() {
parentResource.Document.Size = 12345
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Size:12345", 1)
assertDocCount(rootResource.ID, "Size:>1000", 1)
assertDocCount(rootResource.ID, "Size:<100000", 1)
assertDocCount(rootResource.ID, "Size:12344", 0)
assertDocCount(rootResource.ID, "Size:<1000", 0)
assertDocCount(rootResource.ID, "Size:>100000", 0)
})
It("preserves value case for fields not explicitly marked lowercase", func() {
parentResource.Document.Audio = &libregraph.Audio{
Artist: libregraph.PtrString("Some Artist"),
}
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `audio.artist:"Some Artist"`, 1)
assertDocCount(rootResource.ID, `audio.artist:"some artist"`, 0)
})
})
Context("by filename", func() {
It("finds files with spaces in the filename", func() {
parentResource.Document.Name = "Foo oo.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `name:"foo o*"`, 1)
})
It("finds files by digits in the filename", func() {
parentResource.Document.Name = "12345.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:1234*", 1)
})
It("filters hidden files", func() {
childResource.Hidden = true
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Hidden:T", 1)
assertDocCount(rootResource.ID, "Hidden:F", 0)
})
Context("with a file in the root of the space", func() {
It("scopes the search to the specified space", func() {
parentResource.Document.Name = "foo.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:foo.pdf", 1)
assertDocCount("9$8!7", "Name:foo.pdf", 0)
})
})
It("limits the search to the specified fields", func() {
parentResource.Document.Name = "bar.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:bar.pdf", 1)
assertDocCount(rootResource.ID, "Unknown:field", 0)
})
It("returns the total number of hits", func() {
parentResource.Document.Name = "bar.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
res, err := doSearch(rootResource.ID, "Name:bar*", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(1)))
})
It("returns all desired fields", func() {
parentResource.Document.Name = "bar.pdf"
parentResource.Type = 3
parentResource.MimeType = "application/pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
matches := assertDocCount(rootResource.ID, fmt.Sprintf("Name:%s", parentResource.Name), 1)
match := matches[0]
Expect(match.Entity.Ref.Path).To(Equal(parentResource.Path))
Expect(match.Entity.Name).To(Equal(parentResource.Name))
Expect(match.Entity.Size).To(Equal(parentResource.Size))
Expect(match.Entity.Type).To(Equal(parentResource.Type))
Expect(match.Entity.MimeType).To(Equal(parentResource.MimeType))
Expect(match.Entity.Deleted).To(BeFalse())
Expect(match.Score > 0).To(BeTrue())
})
It("finds files by name, prefix or substring match", func() {
parentResource.Document.Name = "foo.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
queries := []string{"foo.pdf", "foo*", "*oo.p*"}
for _, query := range queries {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, query, 1)
}
})
It("does a case-insensitive search", func() {
parentResource.Document.Name = "foo.pdf"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:foo*", 1)
assertDocCount(rootResource.ID, "Name:Foo*", 1)
})
Context("and an additional file in a subdirectory", func() {
BeforeEach(func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
})
It("finds files living deeper in the tree by filename, prefix or substring match", func() {
queries := []string{"child.pdf", "child*", "*ld.*"}
for _, query := range queries {
assertDocCount(rootResource.ID, query, 1)
}
})
})
})
Context("Highlights", func() {
It("highlights only for content searches", func() {
parentResource.Document.Name = "baz.pdf"
parentResource.Document.Content = "foo bar baz"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
res, err := doSearch(rootResource.ID, "Name:baz*", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(1)))
Expect(res.Matches[0].Entity.Highlights).To(Equal(""))
})
It("highlights search terms", func() {
parentResource.Document.Name = "baz.pdf"
parentResource.Document.Content = "foo bar baz"
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
res, err := doSearch(rootResource.ID, "Content:bar", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(1)))
Expect(res.Matches[0].Entity.Highlights).To(Equal("foo <mark>bar</mark> baz"))
})
})
Context("with a file in the root of the space and folder with a file. all of them have the same name", func() {
BeforeEach(func() {
parentResource := search.Resource{
ID: "1$2!3",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./doc",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER),
Document: content.Document{Name: "doc"},
}
childResource := search.Resource{
ID: "1$2!4",
ParentID: parentResource.ID,
RootID: rootResource.ID,
Path: "./doc/doc.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "doc.pdf"},
}
childResource2 := search.Resource{
ID: "1$2!7",
ParentID: parentResource.ID,
RootID: rootResource.ID,
Path: "./doc/file.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "file.pdf"},
}
rootChildResource := search.Resource{
ID: "1$2!5",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./doc.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "doc.pdf"},
}
rootChildResource2 := search.Resource{
ID: "1$2!6",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./file.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "file.pdf"},
}
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(rootChildResource.ID, rootChildResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(rootChildResource2.ID, rootChildResource2)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource2.ID, childResource2)
Expect(err).ToNot(HaveOccurred())
})
It("search *doc* in a root", func() {
res, err := doSearch(rootResource.ID, "Name:*doc*", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(3)))
})
It("search *doc* in a subfolder", func() {
res, err := doSearch(rootResource.ID, "Name:*doc*", "./doc")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(2)))
})
It("search *file* in a root", func() {
res, err := doSearch(rootResource.ID, "Name:*file*", "")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(2)))
})
It("search *file* in a subfolder", func() {
res, err := doSearch(rootResource.ID, "Name:*file*", "./doc")
Expect(err).ToNot(HaveOccurred())
Expect(res.TotalMatches).To(Equal(int32(1)))
})
})
})
Describe("Upsert", func() {
It("adds a resourceInfo to the index", func() {
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)))
query := bleveSearch.NewMatchQuery("child.pdf")
res, err := idx.Search(bleveSearch.NewSearchRequest(query))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits.Len()).To(Equal(1))
})
It("updates an existing resource in the index", func() {
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
countA, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(countA).To(Equal(uint64(1)))
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
countB, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(countB).To(Equal(uint64(1)))
})
})
Describe("Delete", func() {
It("marks a resource as deleted", func() {
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:*child*", 1)
err = eng.Delete(childResource.ID)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:*child*", 0)
})
It("marks a child resources as deleted", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 1)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
err = eng.Delete(parentResource.ID)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 0)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 0)
})
})
Describe("Restore", func() {
It("also marks child resources as restored", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Delete(parentResource.ID)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Name+`"`, 0)
assertDocCount(rootResource.ID, `"`+childResource.Name+`"`, 0)
err = eng.Restore(parentResource.ID)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Name+`"`, 1)
assertDocCount(rootResource.ID, `"`+childResource.Name+`"`, 1)
})
})
Describe("Purge", func() {
It("removes a resource from the index", func() {
err := eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:child.pdf", 1)
err = eng.Purge(childResource.ID, false)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, "Name:child.pdf", 0)
})
It("removes a resource and its children from the index", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 1)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
err = eng.Purge(parentResource.ID, false)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 0)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 0)
})
It("removes a resource and ignores its children from the index", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 1)
err = eng.Delete(parentResource.ID)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
err = eng.Purge(parentResource.ID, true)
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 0)
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
})
})
Describe("Move", func() {
It("renames the parent and its child resources", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
parentResource.Path = "newname"
err = eng.Move(parentResource.ID, parentResource.ParentID, "./my/newname")
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, parentResource.Name, 0)
matches := assertDocCount(rootResource.ID, "Name:child.pdf", 1)
Expect(matches[0].Entity.ParentId.OpaqueId).To(Equal("3"))
Expect(matches[0].Entity.Ref.Path).To(Equal("./my/newname/child.pdf"))
})
DescribeTable("keeps the flag in step with the path",
func(from, target string, hidden bool) {
parentResource.Path = from
parentResource.Hidden = search.IsHidden(from)
childResource.Path = from + "/child.pdf"
childResource.Hidden = parentResource.Hidden
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
Expect(eng.Upsert(childResource.ID, childResource)).To(Succeed())
Expect(eng.Move(parentResource.ID, parentResource.ParentID, target)).To(Succeed())
for _, id := range []string{parentResource.ID, childResource.ID} {
Expect(hiddenByID(idx, id)).
To(Equal(hidden), "%s after moving from %s to %s", id, from, target)
}
},
Entry("into a dot folder", "./parent", "./.trash/parent", true),
Entry("into a plain folder", "./parent", "./archive/parent", false),
Entry("renamed with a leading dot", "./parent", "./.parent", true),
Entry("out of a dot folder", "./.trash/parent", "./archive/parent", false),
Entry("renamed without the leading dot", "./.parent", "./parent", false),
Entry("within the same dot folder", "./.trash/parent", "./.trash/moved", true),
)
// the trash leaves the path alone, so the flag has to come through untouched
It("carries the flag through the trash and back", func() {
childResource.Path = "./.secret/file.txt"
childResource.Hidden = true
Expect(eng.Upsert(childResource.ID, childResource)).To(Succeed())
Expect(eng.Delete(childResource.ID)).To(Succeed())
Expect(hiddenByID(idx, childResource.ID)).To(BeTrue(), "after trashing")
Expect(eng.Restore(childResource.ID)).To(Succeed())
Expect(hiddenByID(idx, childResource.ID)).To(BeTrue(), "after restoring")
})
It("moves the parent and its child resources", func() {
err := eng.Upsert(parentResource.ID, parentResource)
Expect(err).ToNot(HaveOccurred())
err = eng.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
parentResource.Path = " "
parentResource.ParentID = "1$2!somewhereopaqueid"
err = eng.Move(parentResource.ID, parentResource.ParentID, "./somewhere/else/newname")
Expect(err).ToNot(HaveOccurred())
assertDocCount(rootResource.ID, `parent d!r`, 0)
matches := assertDocCount(rootResource.ID, "Name:child.pdf", 1)
Expect(matches[0].Entity.ParentId.OpaqueId).To(Equal("3"))
Expect(matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname/child.pdf"))
matches = assertDocCount(rootResource.ID, `newname`, 1)
Expect(matches[0].Entity.ParentId.OpaqueId).To(Equal("somewhereopaqueid"))
Expect(matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname"))
})
})
Describe("StartBatch", func() {
It("starts a new batch", func() {
b, err := eng.NewBatch(100)
Expect(err).ToNot(HaveOccurred())
err = b.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(0)))
err = b.Push()
Expect(err).ToNot(HaveOccurred())
count, err = idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)))
query := bleveSearch.NewMatchQuery("child.pdf")
res, err := idx.Search(bleveSearch.NewSearchRequest(query))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits.Len()).To(Equal(1))
})
It("doesn't intertwine different batches", func() {
b, err := eng.NewBatch(100)
Expect(err).ToNot(HaveOccurred())
err = b.Upsert(childResource.ID, childResource)
Expect(err).ToNot(HaveOccurred())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(0)))
b2, err := eng.NewBatch(100)
Expect(err).ToNot(HaveOccurred())
err = b2.Upsert(childResource2.ID, childResource2)
Expect(err).ToNot(HaveOccurred())
Expect(b.Push()).To(Succeed())
count, err = idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)))
Expect(b2.Push()).To(Succeed())
count, err = idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(2)))
})
})
Describe("File type specific metadata", func() {
Context("with audio metadata", func() {
BeforeEach(func() {
resource := search.Resource{
ID: "1$2!7",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./some_song.mp3",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{
Name: "some_song.mp3",
MimeType: "audio/mpeg",
Audio: &libregraph.Audio{
Album: libregraph.PtrString("Some Album"),
AlbumArtist: libregraph.PtrString("Some AlbumArtist"),
Artist: libregraph.PtrString("Some Artist"),
Bitrate: libregraph.PtrInt64(192),
Composers: libregraph.PtrString("Some Composers"),
Copyright: libregraph.PtrString(""),
Disc: libregraph.PtrInt32(2),
DiscCount: libregraph.PtrInt32(5),
Duration: libregraph.PtrInt64(225000),
Genre: libregraph.PtrString("Some Genre"),
HasDrm: libregraph.PtrBool(false),
IsVariableBitrate: libregraph.PtrBool(true),
Title: libregraph.PtrString("Some Title"),
Track: libregraph.PtrInt32(34),
TrackCount: libregraph.PtrInt32(99),
Year: libregraph.PtrInt32(2004),
},
},
}
err := eng.Upsert(resource.ID, resource)
Expect(err).ToNot(HaveOccurred())
})
It("returns audio metadata for search", func() {
matches := assertDocCount(rootResource.ID, `*song*`, 1)
audio := matches[0].Entity.Audio
Expect(audio).ToNot(BeNil())
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
Expect(audio.Copyright).To(Equal(libregraph.PtrString("")))
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(2)))
Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225000)))
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
Expect(audio.Track).To(Equal(libregraph.PtrInt32(34)))
Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(99)))
Expect(audio.Year).To(Equal(libregraph.PtrInt32(2004)))
})
})
Context("with location metadata", func() {
BeforeEach(func() {
resource := search.Resource{
ID: "1$2!7",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./team.jpg",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{
Name: "team.jpg",
MimeType: "image/jpeg",
Location: &libregraph.GeoCoordinates{
Altitude: libregraph.PtrFloat64(1047.7),
Latitude: libregraph.PtrFloat64(49.48675890884328),
Longitude: libregraph.PtrFloat64(11.103870357204285),
},
},
}
err := eng.Upsert(resource.ID, resource)
Expect(err).ToNot(HaveOccurred())
})
It("returns audio metadata for search", func() {
matches := assertDocCount(rootResource.ID, `*team*`, 1)
location := matches[0].Entity.Location
Expect(location).ToNot(BeNil())
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(1047.7)))
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
})
})
})
})
+2 -660
View File
@@ -1,9 +1,6 @@
package opensearch_test
import (
"context"
"fmt"
"strings"
"testing"
. "github.com/onsi/ginkgo/v2"
@@ -11,12 +8,7 @@ import (
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func TestOpenSearchBackend(t *testing.T) {
@@ -24,56 +16,8 @@ func TestOpenSearchBackend(t *testing.T) {
RunSpecs(t, "OpenSearch Backend Suite")
}
func deleteIndexOnCleanup(tc *opensearchtest.TestClient, indexName string) {
DeferCleanup(func() {
Expect(tc.IndicesDelete(context.Background(), []string{indexName})).To(Succeed())
})
}
func resourceByID(tc *opensearchtest.TestClient, index, id string) search.Resource {
GinkgoHelper()
body := opensearchtest.JSONMustMarshal(GinkgoTB(), map[string]any{
"query": map[string]any{
"ids": map[string]any{
"values": []string{id},
},
},
})
resources := opensearchtest.SearchHitsMustBeConverted[search.Resource](GinkgoTB(), tc.Require.Search(index, strings.NewReader(body)).Hits)
Expect(resources).To(HaveLen(1))
return resources[0]
}
// otherRoot returns a copy of the given resource that lives in a different root (space)
// while keeping the same path, so it can be used to assert that cross-root updates do
// not affect identically-named resources in other roots.
func otherRoot(r search.Resource) search.Resource {
r.ID = "2$2!3"
r.RootID = "2$2!1"
r.ParentID = "2$2!2"
return r
}
func newBackend(indexName string, resources ...search.Resource) (*opensearch.Backend, *opensearchtest.TestClient) {
GinkgoHelper()
tc := opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
backend, err := opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
for _, r := range resources {
tc.Require.DocumentCreate(indexName, r.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), r)))
}
tc.Require.IndicesCount([]string{indexName}, nil, len(resources))
return backend, tc
}
// what the engine does with its index is covered for both engines by
// services/search/pkg/parity; this is the one thing only OpenSearch can do
var _ = Describe("Backend", func() {
Describe("NewBackend", func() {
It("fails to create if the cluster is not healthy", func() {
@@ -89,606 +33,4 @@ var _ = Describe("Backend", func() {
Expect(err).To(MatchError(opensearch.ErrUnhealthyCluster))
})
})
Describe("Search", func() {
const indexName = "opencloud-test-engine-search"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
document search.Resource
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
document = opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
})
It("performs the most simple search", func() {
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{
Query: fmt.Sprintf(`"%s"`, document.Name),
})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Matches).To(HaveLen(1))
Expect(resp.TotalMatches).To(Equal(int32(1)))
Expect(fmt.Sprintf("%s$%s!%s", resp.Matches[0].Entity.Id.StorageId, resp.Matches[0].Entity.Id.SpaceId, resp.Matches[0].Entity.Id.OpaqueId)).To(Equal(document.ID))
})
It("ignores files that are marked as deleted", func() {
deletedDocument := opensearchtest.Testdata.Resources.File
deletedDocument.ID = "1$2!4"
deletedDocument.Deleted = true
tc.Require.DocumentCreate(indexName, deletedDocument.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), deletedDocument)))
tc.Require.IndicesCount([]string{indexName}, nil, 2)
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{
Query: fmt.Sprintf(`"%s"`, document.Name),
})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Matches).To(HaveLen(1))
Expect(resp.TotalMatches).To(Equal(int32(1)))
Expect(fmt.Sprintf("%s$%s!%s", resp.Matches[0].Entity.Id.StorageId, resp.Matches[0].Entity.Id.SpaceId, resp.Matches[0].Entity.Id.OpaqueId)).To(Equal(document.ID))
})
})
Describe("Upsert", func() {
const indexName = "opencloud-test-engine-upsert"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
})
It("upserts a full document", func() {
document := opensearchtest.Testdata.Resources.File
Expect(backend.Upsert(document.ID, document)).To(Succeed())
tc.Require.IndicesCount([]string{indexName}, nil, 1)
})
})
Describe("Move", func() {
const indexName = "opencloud-test-engine-move"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
})
It("moves the document to a new path", func() {
document := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
body := opensearchtest.JSONMustMarshal(GinkgoTB(), map[string]any{
"query": map[string]any{
"ids": map[string]any{
"values": []string{document.ID},
},
},
})
resources := opensearchtest.SearchHitsMustBeConverted[search.Resource](GinkgoTB(), tc.Require.Search(indexName, strings.NewReader(body)).Hits)
Expect(resources).To(HaveLen(1))
Expect(resources[0].Path).To(Equal(document.Path))
document.Path = "./new/path/to/resource"
Expect(backend.Move(document.ID, document.ParentID, document.Path)).To(Succeed())
resources = opensearchtest.SearchHitsMustBeConverted[search.Resource](GinkgoTB(), tc.Require.Search(indexName, strings.NewReader(body)).Hits)
Expect(resources).To(HaveLen(1))
Expect(resources[0].Path).To(Equal(document.Path))
})
})
Describe("WriteVisibility", func() {
const indexName = "opencloud-test-engine-write-visibility"
It("deletes a record that was just written", func() {
document := opensearchtest.Testdata.Resources.File
document.ID = "1$1!95"
document.Name = "textfile.txt"
document.Path = "./textfile.txt"
backend, tc := newBackend(indexName)
deleteIndexOnCleanup(tc, indexName)
Expect(backend.Upsert(document.ID, document)).To(Succeed())
Expect(backend.Delete(document.ID)).To(Succeed())
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{
Query: fmt.Sprintf(`name:"%s"`, document.Name),
})
Expect(err).ToNot(HaveOccurred())
Expect(resp.Matches).To(BeEmpty())
})
})
Describe("Delete", func() {
const indexName = "opencloud-test-engine-delete"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
})
It("marks the document as deleted", func() {
document := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
body := opensearchtest.JSONMustMarshal(GinkgoTB(), map[string]any{
"query": map[string]any{
"term": map[string]any{
"Deleted": map[string]any{
"value": true,
},
},
},
})
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 0)
Expect(backend.Delete(document.ID)).To(Succeed())
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 1)
})
})
Describe("Restore", func() {
const indexName = "opencloud-test-engine-restore"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
})
It("marks the document as not deleted", func() {
document := opensearchtest.Testdata.Resources.File
document.Deleted = true
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
body := opensearchtest.JSONMustMarshal(GinkgoTB(), map[string]any{
"query": map[string]any{
"term": map[string]any{
"Deleted": map[string]any{
"value": true,
},
},
},
})
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 1)
Expect(backend.Restore(document.ID)).To(Succeed())
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 0)
})
})
Describe("Purge", func() {
const indexName = "opencloud-test-engine-purge"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
})
It("purges a full document", func() {
document := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
Expect(backend.Purge(document.ID, false)).To(Succeed())
tc.Require.IndicesCount([]string{indexName}, nil, 0)
})
It("purges resource trees", func() {
resourceFolder := opensearchtest.Testdata.Resources.Folder
tc.Require.DocumentCreate(indexName, resourceFolder.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), resourceFolder)))
resourceFile := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, resourceFile.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), resourceFile)))
tc.Require.IndicesCount([]string{indexName}, nil, 2)
Expect(backend.Purge(resourceFolder.ID, false)).To(Succeed())
tc.Require.IndicesCount([]string{indexName}, nil, 0)
})
It("purges resource trees and ignores undeleted resources", func() {
resourceFolder := opensearchtest.Testdata.Resources.Folder
tc.Require.DocumentCreate(indexName, resourceFolder.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), resourceFolder)))
resourceFile := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, resourceFile.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), resourceFile)))
tc.Require.IndicesCount([]string{indexName}, nil, 2)
Expect(backend.Delete(resourceFile.ID)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
Expect(backend.Purge(resourceFolder.ID, true)).To(Succeed())
tc.Require.IndicesCount([]string{indexName}, nil, 1)
})
})
Describe("PurgeSpace", func() {
const indexName = "opencloud-test-engine-purge-space"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
})
It("takes every record of that space out of the index", func() {
gone := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, gone.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), gone)))
stays := opensearchtest.Testdata.Resources.File
stays.ID = "1$2!3"
stays.RootID = "1$2!2"
tc.Require.DocumentCreate(indexName, stays.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), stays)))
tc.Require.IndicesCount([]string{indexName}, nil, 2)
Expect(backend.PurgeSpace(gone.RootID)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
left := opensearchtest.SearchHitsMustBeConverted[search.Resource](
GinkgoTB(),
tc.Require.Search(indexName, strings.NewReader(`{"query":{"match_all":{}}}`)).Hits,
)
Expect(left).To(HaveLen(1), "only the records of that space are gone")
Expect(left[0].ID).To(Equal(stays.ID))
})
})
Describe("Hidden", func() {
const indexName = "opencloud-test-engine-hidden"
DescribeTable("keeps the flag in step with the path",
func(from, target string, hidden bool) {
folder := opensearchtest.Testdata.Resources.Folder
folder.ID = "1$1!30"
folder.Name = "parent"
folder.Path = from
folder.Hidden = search.IsHidden(from)
child := opensearchtest.Testdata.Resources.File
child.ID = "1$1!31"
child.Name = "child.txt"
child.Path = from + "/child.txt"
child.ParentID = folder.ID
child.Hidden = folder.Hidden
backend, tc := newBackend(indexName, folder, child)
deleteIndexOnCleanup(tc, indexName)
tc.Require.IndicesRefresh([]string{indexName}, nil)
Expect(backend.Move(folder.ID, folder.ParentID, target)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
for _, id := range []string{folder.ID, child.ID} {
Expect(resourceByID(tc, indexName, id).Hidden).
To(Equal(hidden), "%s after moving from %s to %s", id, from, target)
}
},
Entry("into a dot folder", "./parent", "./.trash/parent", true),
Entry("into a plain folder", "./parent", "./archive/parent", false),
Entry("renamed with a leading dot", "./parent", "./.parent", true),
Entry("out of a dot folder", "./.trash/parent", "./archive/parent", false),
Entry("renamed without the leading dot", "./.parent", "./parent", false),
Entry("within the same dot folder", "./.trash/parent", "./.trash/moved", true),
)
It("carries the flag through the trash and back", func() {
hidden := opensearchtest.Testdata.Resources.File
hidden.ID = "1$1!32"
hidden.Path = "./.secret/file.txt"
hidden.Hidden = true
backend, tc := newBackend(indexName, hidden)
deleteIndexOnCleanup(tc, indexName)
tc.Require.IndicesRefresh([]string{indexName}, nil)
Expect(backend.Delete(hidden.ID)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
Expect(resourceByID(tc, indexName, hidden.ID).Hidden).To(BeTrue(), "after trashing")
Expect(backend.Restore(hidden.ID)).To(Succeed())
tc.Require.IndicesRefresh([]string{indexName}, nil)
Expect(resourceByID(tc, indexName, hidden.ID).Hidden).To(BeTrue(), "after restoring")
})
})
Describe("DocCount", func() {
const indexName = "opencloud-test-engine-doc-count"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
tc.Require.IndicesCount([]string{indexName}, nil, 0)
deleteIndexOnCleanup(tc, indexName)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
})
It("ignores deleted documents", func() {
document := opensearchtest.Testdata.Resources.File
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), document)))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
count, err := backend.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)))
tc.Require.Update(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), map[string]any{
"doc": map[string]any{
"Deleted": true,
},
})))
tc.Require.IndicesCount([]string{indexName}, nil, 1)
count, err = backend.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(0)))
})
})
// The following specs ensure that updates which affect a resource and its descendants
// (Delete, Restore, Move) are scoped to the root (space) of the target resource. Two
// resources living in different roots may share the exact same path, so matching by
// path alone would incorrectly update the wrong resource.
Describe("updateSelfAndDescendants root scope", func() {
It("deletes only the resource in the target root", func() {
const indexName = "opencloud-test-engine-root-scope-delete"
target := opensearchtest.Testdata.Resources.File
other := otherRoot(target)
backend, tc := newBackend(indexName, target, other)
deleteIndexOnCleanup(tc, indexName)
Expect(backend.Delete(target.ID)).To(Succeed())
Expect(resourceByID(tc, indexName, target.ID).Deleted).To(BeTrue(), "target resource should be marked as deleted")
Expect(resourceByID(tc, indexName, other.ID).Deleted).To(BeFalse(), "resource in a different root must not be affected")
})
It("restores only the resource in the target root", func() {
const indexName = "opencloud-test-engine-root-scope-restore"
target := opensearchtest.Testdata.Resources.File
target.Deleted = true
other := otherRoot(target)
backend, tc := newBackend(indexName, target, other)
deleteIndexOnCleanup(tc, indexName)
Expect(backend.Restore(target.ID)).To(Succeed())
Expect(resourceByID(tc, indexName, target.ID).Deleted).To(BeFalse(), "target resource should be restored")
Expect(resourceByID(tc, indexName, other.ID).Deleted).To(BeTrue(), "resource in a different root must not be affected")
})
It("moves only the resource in the target root", func() {
const indexName = "opencloud-test-engine-root-scope-move"
target := opensearchtest.Testdata.Resources.File
other := otherRoot(target)
backend, tc := newBackend(indexName, target, other)
deleteIndexOnCleanup(tc, indexName)
Expect(backend.Move(target.ID, target.ParentID, "./new/path/to/resource")).To(Succeed())
Expect(resourceByID(tc, indexName, target.ID).Path).To(Equal("./new/path/to/resource"), "target resource should be moved")
Expect(resourceByID(tc, indexName, other.ID).Path).To(Equal(other.Path), "resource in a different root must not be moved")
})
})
Describe("SearchInAnalyzedFields", func() {
const indexName = "opencloud-test-engine-search-analyzed-fields"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
dashed := opensearchtest.Testdata.Resources.Folder
dashed.ID = "1$1!10"
dashed.Name = "new-folder"
dashed.Path = "./new-folder"
dashed.Title = "quarterly report"
plain := opensearchtest.Testdata.Resources.Folder
plain.ID = "1$1!11"
plain.Name = "documents"
plain.Path = "./documents"
plain.Title = "notes"
spaced := opensearchtest.Testdata.Resources.Folder
spaced.ID = "1$1!12"
spaced.Name = "foo bar"
spaced.Path = "./foo bar"
spaced.Title = "spaced out"
backend, tc = newBackend(indexName, dashed, plain, spaced)
deleteIndexOnCleanup(tc, indexName)
tc.Require.IndicesRefresh([]string{indexName}, nil)
})
DescribeTable("finds what the analyzer made of the value",
func(query string, want []string) {
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: query})
Expect(err).ToNot(HaveOccurred())
names := make([]string, 0, len(resp.Matches))
for _, match := range resp.Matches {
names = append(names, match.Entity.Name)
}
Expect(names).To(ConsistOf(want))
},
Entry("the full name with the dash", "new-folder", []string{"new-folder"}),
Entry("one token of it", "new", []string{"new-folder"}),
Entry("a name without a dash", "documents", []string{"documents"}),
Entry("a wildcard", "*folder*", []string{"new-folder"}),
// the shape the web client sends for every name search
Entry("a wildcard around the whole dashed name", `name:"*new-folder*"`, []string{"new-folder"}),
Entry("a wildcard spanning the dash", `name:"*w-fol*"`, []string{"new-folder"}),
Entry("a wildcard in a different case", `name:"*NEW-FOLDER*"`, []string{"new-folder"}),
Entry("a wildcard spanning a space", `name:"*oo ba*"`, []string{"foo bar"}),
Entry("a wildcard around a name with a space", `name:"*foo bar*"`, []string{"foo bar"}),
Entry("a name with a space", `name:"foo bar"`, []string{"foo bar"}),
Entry("a title of two words", `Title:"quarterly report"`, []string{"new-folder"}),
Entry("one token of a title", "Title:quarterly", []string{"new-folder"}),
)
})
Describe("SearchByTag", func() {
const indexName = "opencloud-test-engine-search-by-tag"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
tagged := opensearchtest.Testdata.Resources.Folder
tagged.ID = "1$1!20"
tagged.Name = "tagged"
tagged.Path = "./tagged"
tagged.Tags = []string{"foo-bar"}
other := opensearchtest.Testdata.Resources.Folder
other.ID = "1$1!21"
other.Name = "other"
other.Path = "./other"
other.Tags = []string{"foo"}
backend, tc = newBackend(indexName, tagged, other)
deleteIndexOnCleanup(tc, indexName)
tc.Require.IndicesRefresh([]string{indexName}, nil)
})
// a tag is one label, not prose, so it matches as a whole or not at all
DescribeTable("matches a tag as a whole",
func(query string, want []string) {
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: query})
Expect(err).ToNot(HaveOccurred())
names := make([]string, 0, len(resp.Matches))
for _, match := range resp.Matches {
names = append(names, match.Entity.Name)
}
Expect(names).To(ConsistOf(want))
},
Entry("the whole tag", `tag:("foo-bar")`, []string{"tagged"}),
Entry("a token of a tag does not match it", `tag:("foo")`, []string{"other"}),
Entry("a tag in a different case", `tag:("FOO-BAR")`, []string{"tagged"}),
Entry("a wildcard reaches both", `tag:("*foo*")`, []string{"tagged", "other"}),
)
})
Describe("SearchWithAnInvalidQuery", func() {
const indexName = "opencloud-test-engine-search-invalid-query"
It("answers with a bad request", func() {
backend, tc := newBackend(indexName)
deleteIndexOnCleanup(tc, indexName)
_, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: "AND mediatype:document"})
Expect(err).To(HaveOccurred())
Expect(err).To(BeAssignableToTypeOf(errtypes.BadRequest("")))
Expect(err.Error()).To(Equal(`error: bad request: the expression can't begin from a binary operator: 'AND'`))
})
})
})
+1 -1
View File
@@ -7,8 +7,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/tidwall/sjson"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestIndexManager(t *testing.T) {
@@ -9,7 +9,7 @@ import (
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
)
func TestExpandKQLAST(t *testing.T) {
@@ -7,9 +7,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestTranspileKQLToOpenSearch(t *testing.T) {
@@ -10,8 +10,8 @@ import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
@@ -5,8 +5,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestBoolQuery(t *testing.T) {
@@ -5,8 +5,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestNewMatchPhraseQuery(t *testing.T) {
@@ -5,8 +5,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestIDsQuery(t *testing.T) {
@@ -8,8 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestRangeQuery(t *testing.T) {
@@ -5,8 +5,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestTermQuery(t *testing.T) {
@@ -5,8 +5,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestWildcardQuery(t *testing.T) {
@@ -8,8 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
func TestRequestBody(t *testing.T) {
@@ -6,8 +6,8 @@ import (
"os"
"testing"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
)
var defaultConfig *config.Config
+631
View File
@@ -0,0 +1,631 @@
# Engine parity
Written by the parity suite (`go test ./services/search/pkg/parity/`), do not edit.
Every case runs against bleve and OpenSearch. `same?` is ✅ when both answer as
expected, `❌ known` when an engine's divergence is documented in the case
(`engineOverrides`), `❌` when it is not.
## Queries
### name
Fixtures:
- `new-folder`, folder
- `quarterly notes.txt`
- `Report.txt`
- `Übung.txt`
- `a+b.txt`
- `c(d).txt`
- `e&f.txt`
- `v1.2.3.txt`
- `foo bar.txt`
- `aaaaaaaaaa...edle.txt`
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| NAME-01 | `new` | new-folder | no match | new-folder | ❌ known |
| NAME-02 | `quarterly` | quarterly notes.txt | no match | quarterly notes.txt | ❌ known |
| NAME-03 | `report` | Report.txt | no match | no match | ❌ known |
| NAME-04 | `name:"*new-folder*"` | new-folder | new-folder | new-folder | ✅ |
| NAME-05 | `name:"*w-fol*"` | new-folder | new-folder | new-folder | ✅ |
| NAME-06 | `name:"*oo ba*"` | foo bar.txt | foo bar.txt | foo bar.txt | ✅ |
| NAME-07 | `name:"*REPORT*"` | Report.txt | Report.txt | Report.txt | ✅ |
| NAME-08 | `name:"*übung*"` | Übung.txt | Übung.txt | no match | ❌ known |
| NAME-09 | `name:"*ÜBUNG*"` | Übung.txt | Übung.txt | no match | ❌ known |
| NAME-10 | `name:"*a+b*"` | a+b.txt | a+b.txt | a+b.txt | ✅ |
| NAME-11 | `name:"*c(d)*"` | c(d).txt | c(d).txt | c(d).txt | ✅ |
| NAME-12 | `name:"*e&f*"` | e&f.txt | e&f.txt | e&f.txt | ✅ |
| NAME-13 | `name:"*v1.2*"` | v1.2.3.txt | v1.2.3.txt | v1.2.3.txt | ✅ |
| NAME-14 | `new-folder` | new-folder | new-folder | new-folder | ✅ |
| NAME-15 | `*folder*` | new-folder | new-folder | new-folder | ✅ |
| NAME-16 | `name:"*foo bar*"` | foo bar.txt | foo bar.txt | foo bar.txt | ✅ |
| NAME-17 | `name:"foo bar.txt"` | foo bar.txt | foo bar.txt | foo bar.txt | ✅ |
| NAME-18 | `name:"*needle*"` | aaaaaaaaaa...edle.txt | aaaaaaaaaa...edle.txt | no match | ❌ known |
| NAME-19 | `name:"report*"` | Report.txt | Report.txt | Report.txt | ✅ |
| NAME-20 | `name:"*report"` | Report.txt | no match | no match | ❌ known |
| NAME-21 | `name:"Rep*rt.txt"` | Report.txt | Report.txt | Report.txt | ✅ |
| NAME-22 | `Name:"*report*"` | Report.txt | Report.txt | Report.txt | ✅ |
| NAME-23 | `NAME:"*report*"` | Report.txt | Report.txt | no match | ❌ known |
| NAME-24 | `name:Rep?rt.txt` | Report.txt | Report.txt | no match | ❌ known |
| NAME-25 | `name:"*eport"` | Report.txt | no match | no match | ❌ known |
| NAME-26 | `name:"repor*"` | Report.txt | Report.txt | Report.txt | ✅ |
| NAME-27 | `REPORT` | Report.txt | no match | no match | ❌ known |
| NAME-28 | `name:REPORT` | Report.txt | no match | no match | ❌ known |
| NAME-29 | `name:"REPORT.TXT"` | Report.txt | Report.txt | Report.txt | ✅ |
| NAME-30 | `name:"FOO BAR.TXT"` | foo bar.txt | foo bar.txt | foo bar.txt | ✅ |
| NAME-31 | `name:"ÜBUNG.TXT"` | Übung.txt | Übung.txt | Übung.txt | ✅ |
| NAME-32 | `name:"folder*"` | no match | no match | no match | ✅ |
| NAME-33 | `name:"*new"` | no match | no match | no match | ✅ |
| NAME-34 | `name:new` | new-folder | no match | new-folder | ❌ known |
| NAME-35 | `name:"new"` | no match | no match | new-folder | ❌ known |
| NAME-36 | `name:"new-folder"` | new-folder | new-folder | new-folder | ✅ |
| NAME-37 | `name:"new-*"` | new-folder | new-folder | new-folder | ✅ |
| NAME-38 | `name:"new*"` | new-folder | new-folder | new-folder | ✅ |
| NAME-39 | `name:new-*` | new-folder | new-folder | new-folder | ✅ |
| NAME-40 | `name:"*-folder"` | new-folder | new-folder | new-folder | ✅ |
| NAME-41 | `name:"Rep?rt.txt"` | Report.txt | Report.txt | no match | ❌ known |
### extension
Fixtures:
- `report.txt`
- `notes.md`, MimeType = text/markdown
- `archive`, folder
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| EXTENSION-01 | `txt` | report.txt | no match | no match | ❌ known |
| EXTENSION-02 | `md` | notes.md | no match | no match | ❌ known |
| EXTENSION-03 | `name:"*.txt"` | report.txt | report.txt | report.txt | ✅ |
| EXTENSION-04 | `report` | report.txt | no match | no match | ❌ known |
### tags
Fixtures:
- `invoice.txt`, Tags = foo-bar
- `memo.txt`, Tags = foo
- `spaced.txt`, Tags = spaced tag
- `project`, folder, Tags = work
- `draft.txt`, Path = ./project/draft.txt
- `longtag.txt`, Tags = zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzneedle
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| TAGS-01 | `name:"*foo-bar*"` | no match | no match | no match | ✅ |
| TAGS-02 | `tag:("foo-bar")` | invoice.txt | invoice.txt | invoice.txt | ✅ |
| TAGS-03 | `tag:("foo")` | memo.txt | memo.txt | memo.txt | ✅ |
| TAGS-04 | `tag:("FOO-BAR")` | invoice.txt | invoice.txt | invoice.txt | ✅ |
| TAGS-05 | `tag:("*foo*")` | invoice.txt, memo.txt | invoice.txt, memo.txt | invoice.txt, memo.txt | ✅ |
| TAGS-06 | `tag:("spaced tag")` | spaced.txt | spaced.txt | spaced.txt | ✅ |
| TAGS-07 | `tag:("*paced ta*")` | spaced.txt | spaced.txt | spaced.txt | ✅ |
| TAGS-08 | `tag:("work")` | project | project | project | ✅ |
| TAGS-09 | `tag:("zzzzzzzzzzzzzzzzzzzzzzzzzz...zzzzzzzzzzzzzzzzneedle")` | longtag.txt | longtag.txt | no match | ❌ known |
### title
Fixtures:
- `q1.html`, MimeType = text/html, Title = "quarterly report"
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| TITLE-01 | `Title:"quarterly report"` | q1.html | q1.html | q1.html | ✅ |
| TITLE-02 | `Title:quarterly` | q1.html | no match | q1.html | ❌ known |
| TITLE-03 | `Title:QUARTERLY` | q1.html | no match | q1.html | ❌ known |
| TITLE-04 | `Title:quarterl*` | q1.html | q1.html | q1.html | ✅ |
| TITLE-05 | `Title:"*ly rep*"` | q1.html | q1.html | q1.html | ✅ |
| TITLE-06 | `title:quarterly` | q1.html | no match | no match | ❌ known |
| TITLE-07 | `Title:"QUARTERLY REPORT"` | q1.html | no match | q1.html | ❌ known |
### content
Fixtures:
- `monthly.txt`, Content = "the monthly reports are due"
- `links.txt`, Content = "see https://opencloud.example.com/help or write to alan@example.org"
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| CONTENT-01 | `Content:report` | no match | monthly.txt | no match | ❌ known |
| CONTENT-02 | `Content:REPORTS` | monthly.txt | monthly.txt | monthly.txt | ✅ |
| CONTENT-03 | `Content:"monthly reports"` | monthly.txt | monthly.txt | monthly.txt | ✅ |
| CONTENT-04 | `Content:"reports monthly"` | no match | monthly.txt | no match | ❌ known |
| CONTENT-05 | `Content:report*` | monthly.txt | monthly.txt | monthly.txt | ✅ |
| CONTENT-06 | `Content:*eport*` | monthly.txt | monthly.txt | monthly.txt | ✅ |
| CONTENT-07 | `Content:month*` | monthly.txt | monthly.txt | monthly.txt | ✅ |
| CONTENT-08 | `Content:"https://opencloud.example.com/help"` | links.txt | links.txt | links.txt | ✅ |
| CONTENT-09 | `Content:"alan@example.org"` | links.txt | links.txt | links.txt | ✅ |
| CONTENT-10 | `Content:opencloud` | links.txt | no match | no match | ❌ known |
### favorites
Fixtures:
- `starred.txt`, Favorites = A1B2-Upper
- `plain.txt`
- `keepsakes`, folder, Favorites = A1B2-Upper
- `photo.jpg`, MimeType = image/jpeg, Path = ./keepsakes/photo.jpg
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| FAVORITES-01 | `Favorites:"A1B2-Upper"` | keepsakes, starred.txt | keepsakes, starred.txt | no match | ❌ known |
| FAVORITES-02 | `favorite:"A1B2-Upper"` | keepsakes, starred.txt | keepsakes, starred.txt | no match | ❌ known |
| FAVORITES-03 | `Favorites:"somebody-else"` | no match | no match | no match | ✅ |
### mediatype
Fixtures:
- `notes.md`, MimeType = text/markdown
- `photo.jpg`, MimeType = image/jpeg
- `albums`, folder
- `drafts`, folder
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| MEDIATYPE-01 | `mediatype:text/markdown` | notes.md | notes.md | notes.md | ✅ |
| MEDIATYPE-02 | `mediatype:TEXT/MARKDOWN` | notes.md | no match | notes.md | ❌ known |
| MEDIATYPE-03 | `mediatype:image/jpeg` | photo.jpg | photo.jpg | photo.jpg | ✅ |
| MEDIATYPE-04 | `mediatype:*jpeg` | photo.jpg | photo.jpg | photo.jpg | ✅ |
| MEDIATYPE-05 | `mediatype:image` | photo.jpg | photo.jpg | photo.jpg | ✅ |
| MEDIATYPE-06 | `mediatype:folder` | albums, drafts | albums, drafts | albums, drafts | ✅ |
### path
Fixtures:
- `parent`, folder
- `child.jpg`, MimeType = image/jpeg, Path = ./parent/child.jpg
- `docs-lower`, folder, Path = ./documents
- `docs-upper`, folder, Path = ./DOCUMENTS
- `docs-mixed`, folder, Path = ./Documents
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| PATH-01 | `path:"./parent"` | child.jpg, parent | parent | child.jpg, parent | ❌ known |
| PATH-02 | `path:"./parent/child.jpg"` | child.jpg | child.jpg | child.jpg | ✅ |
| PATH-03 | `path:"./Parent"` | no match | no match | child.jpg, parent | ❌ known |
| PATH-04 | `path:"*child*"` | child.jpg | child.jpg | child.jpg | ✅ |
| PATH-05 | `path:"./documents"` | docs-lower | docs-lower | docs-lower, docs-mixed, docs-upper | ❌ known |
| PATH-06 | `path:"./DOCUMENTS"` | docs-upper | docs-upper | docs-lower, docs-mixed, docs-upper | ❌ known |
| PATH-07 | `path:"./Documents"` | docs-mixed | docs-mixed | docs-lower, docs-mixed, docs-upper | ❌ known |
| PATH-08 | `path:"./parent/"` | child.jpg, parent | no match | no match | ❌ known |
### fields
Fixtures:
- `small.txt`, ID = 1$1!small.txt, Size = 42
- `old.txt`, ID = 1$1!old.txt, Mtime = 2020-01-01T00:00:00Z
- `known.txt`, ID = 1$1!23
- `cased.txt`, ID = 1$1!AB-23
- `hidden.txt`, ID = 1$1!hidden.txt, hidden
- `plain.txt`, ID = 1$1!plain.txt
- `box`, ID = 1$1!box, folder
- `boxed.txt`, ID = 1$1!boxed.txt, Path = ./box/boxed.txt
- `song.mp3`, ID = 1$1!song.mp3, MimeType = audio/mpeg
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| FIELDS-01 | `size:42` | small.txt | small.txt | small.txt | ✅ |
| FIELDS-02 | `mtime<"2021-01-01T00:00:00Z"` | old.txt | old.txt | old.txt | ✅ |
| FIELDS-03 | `id:"1$1!23"` | known.txt | known.txt | known.txt | ✅ |
| FIELDS-04 | `hidden:true` | hidden.txt | no match | hidden.txt | ❌ known |
| FIELDS-05 | `type:file` | boxed.txt, cased.txt, hidden.txt, known.txt, old.txt, plain.txt, small.txt | no match | error | ❌ known |
| FIELDS-06 | `type:folder` | box | no match | error | ❌ known |
| FIELDS-07 | `unknown:field` | no match | no match | no match | ✅ |
| FIELDS-08 | `type:File` | boxed.txt, cased.txt, hidden.txt, known.txt, old.txt, plain.txt, small.txt | no match | error | ❌ known |
| FIELDS-09 | `type:FOLDER` | box | no match | error | ❌ known |
| FIELDS-10 | `hidden:TRUE` | hidden.txt | no match | error | ❌ known |
| FIELDS-11 | `id:"1$1!AB-23"` | cased.txt | cased.txt | no match | ❌ known |
| FIELDS-12 | `id:"1$1!ab-23"` | no match | no match | no match | ✅ |
| FIELDS-13 | `audio.artist:"Some Artist"` | song.mp3 | song.mp3 | no match | ❌ known |
| FIELDS-14 | `audio.artist:"some artist"` | no match | no match | no match | ✅ |
### deleted
Fixtures:
- `trashed.txt`, deleted
- `kept.txt`
- `bin`, folder, deleted
- `receipt.txt`, Path = ./bin/receipt.txt, deleted
- `shelf`, folder
- `book.txt`, Path = ./shelf/book.txt
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| DELETED-01 | `name:"*trashed*"` | no match | no match | no match | ✅ |
| DELETED-02 | `name:"*.txt"` | book.txt, kept.txt | book.txt, kept.txt | book.txt, kept.txt | ✅ |
| DELETED-03 | `name:"*receipt*"` | no match | no match | no match | ✅ |
| DELETED-04 | `path:"./bin"` | no match | no match | no match | ✅ |
| DELETED-05 | `path:"./shelf"` | book.txt, shelf | shelf | book.txt, shelf | ❌ known |
### visibility
Fixtures:
- `visible.txt`
- `dotfile.txt`, hidden
- `.private`, folder, hidden
- `secret.txt`, Path = ./.private/secret.txt, hidden
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| VISIBILITY-01 | `hidden:true` | .private, dotfile.txt, secret.txt | no match | .private, dotfile.txt, secret.txt | ❌ known |
| VISIBILITY-02 | `hidden:TRUE` | .private, dotfile.txt, secret.txt | no match | error | ❌ known |
| VISIBILITY-03 | `hidden:false` | visible.txt | no match | visible.txt | ❌ known |
| VISIBILITY-04 | `name:"*secret*"` | secret.txt | secret.txt | secret.txt | ✅ |
| VISIBILITY-05 | `path:"./.private"` | .private, secret.txt | .private | .private, secret.txt | ❌ known |
| VISIBILITY-06 | `hidden:banana` | no match | no match | error | ❌ known |
| VISIBILITY-07 | `hidden:"true"` | .private, dotfile.txt, secret.txt | no match | .private, dotfile.txt, secret.txt | ❌ known |
### boolean
Fixtures:
- `alpha.txt`, Tags = red
- `beta.txt`, Tags = blue
- `gamma.md`, MimeType = text/markdown
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| BOOLEAN-01 | `name:"*alpha*" AND name:"*txt*"` | alpha.txt | alpha.txt | alpha.txt | ✅ |
| BOOLEAN-02 | `name:"*alpha*" OR name:"*beta*"` | alpha.txt, beta.txt | alpha.txt, beta.txt | alpha.txt, beta.txt | ✅ |
| BOOLEAN-03 | `name:"*a*" AND NOT name:"*alpha*"` | beta.txt, gamma.md | beta.txt, gamma.md | beta.txt, gamma.md | ✅ |
| BOOLEAN-04 | `name:"*a*" AND tag:("red")` | alpha.txt | alpha.txt | alpha.txt | ✅ |
| BOOLEAN-05 | `(name:"*alpha*" OR name:"*beta*") AND mediatype:text/plain` | alpha.txt, beta.txt | alpha.txt, beta.txt | alpha.txt, beta.txt | ✅ |
| BOOLEAN-06 | `name:"*a*" AND mediatype:text/markdown` | gamma.md | gamma.md | gamma.md | ✅ |
### samename
Fixtures:
- `doc`, folder
- `doc.pdf`
- `file.pdf`
- `doc.pdf`, Path = ./doc/doc.pdf
- `file.pdf`, Path = ./doc/file.pdf
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| SAMENAME-01 | `name:"*doc*"` | 3 items | doc, doc.pdf, doc.pdf | doc, doc.pdf, doc.pdf | ✅ |
| SAMENAME-02 | `name:"*doc*"` in 1$1!1 under ./doc | 2 items | doc, doc.pdf | doc, doc.pdf | ✅ |
| SAMENAME-03 | `name:"*file*"` | 2 items | file.pdf, file.pdf | file.pdf, file.pdf | ✅ |
| SAMENAME-04 | `name:"*file*"` in 1$1!1 under ./doc | 1 items | file.pdf | file.pdf | ✅ |
### stress
Fixtures:
- `quarterly report.docx`, MimeType = application/vnd.openxmlformats-officedocument.wordprocessingml.document, Tags = final, Size = 2000
- `draft report.txt`, Tags = draft, Size = 50, Mtime = 2020-01-01T00:00:00Z
- `photo.jpg`, MimeType = image/jpeg, Tags = final
- `notes.md`, MimeType = text/markdown, hidden
- `archive`, folder
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| STRESS-01 | `name:"*report*" AND mediatype:document` | draft report.txt, quarterly report.docx | draft report.txt, quarterly report.docx | draft report.txt, quarterly report.docx | ✅ |
| STRESS-02 | `name:"*report*" AND NOT tag:("draft")` | quarterly report.docx | draft report.txt, quarterly report.docx | quarterly report.docx | ❌ known |
| STRESS-03 | `(tag:("final") OR tag:("draft")) AND mediatype:image` | photo.jpg | photo.jpg | photo.jpg | ✅ |
| STRESS-04 | `mediatype:document AND mtime>"2021-01-01T00:00:00Z"` | notes.md, quarterly report.docx | notes.md, quarterly report.docx | notes.md, quarterly report.docx | ✅ |
| STRESS-05 | `name:"*report*" AND size>100` | quarterly report.docx | no match | no match | ❌ known |
| STRESS-06 | `tag:("final") AND NOT mediatype:folder` | photo.jpg, quarterly report.docx | photo.jpg, quarterly report.docx | photo.jpg, quarterly report.docx | ✅ |
| STRESS-07 | `hidden:true AND name:"*notes*"` | notes.md | no match | notes.md | ❌ known |
| STRESS-08 | `name:quarterly report` | quarterly report.docx | no match | no match | ❌ known |
| STRESS-09 | `name:"quarterly report"` | no match | no match | no match | ✅ |
| STRESS-10 | `name:"quarterly report.docx"` | quarterly report.docx | quarterly report.docx | quarterly report.docx | ✅ |
| STRESS-11 | `NOT tag:("draft")` | archive, notes.md, photo.jpg, quarterly report.docx | archive, draft report.txt, notes.md, photo.jpg, quarterly report.docx | archive, notes.md, photo.jpg, quarterly report.docx | ❌ known |
| STRESS-12 | `tag:("final") OR hidden:true` | notes.md, photo.jpg, quarterly report.docx | photo.jpg, quarterly report.docx | notes.md, photo.jpg, quarterly report.docx | ❌ known |
| STRESS-13 | `(name:"*report*" OR name:"*notes..."draft") OR hidden:true)` | quarterly report.docx | notes.md, quarterly report.docx | quarterly report.docx | ❌ known |
| STRESS-14 | `mediatype:image OR (mediatype:document AND tag:("draft"))` | draft report.txt, photo.jpg | draft report.txt, photo.jpg | draft report.txt, photo.jpg | ✅ |
| STRESS-15 | `NOT (mediatype:folder OR hidden:true)` | draft report.txt, photo.jpg, quarterly report.docx | draft report.txt, notes.md, photo.jpg, quarterly report.docx | draft report.txt, photo.jpg, quarterly report.docx | ❌ known |
| STRESS-16 | `name:"*report*" AND (size>100 OR tag:("draft"))` | draft report.txt, quarterly report.docx | draft report.txt | draft report.txt | ❌ known |
### everything
Fixtures:
- `alpha.txt`
- `beta.txt`
- `box`, folder
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| EVERYTHING-01 | `*` | alpha.txt, beta.txt, box | alpha.txt, beta.txt, box | alpha.txt, beta.txt, box | ✅ |
| EVERYTHING-02 | `name:"*"` | alpha.txt, beta.txt, box | alpha.txt, beta.txt, box | alpha.txt, beta.txt, box | ✅ |
| EVERYTHING-03 | `*` with a limit of 2 | 2 items | alpha.txt, beta.txt | alpha.txt, beta.txt | ✅ |
| EVERYTHING-04 | `*` with a limit of -1 | 3 items | alpha.txt, beta.txt, box | alpha.txt, beta.txt, box | ✅ |
### range
Fixtures:
- `small.txt`, Size = 50
- `big.txt`, Size = 500
- `ancient.txt`, Size = 10, Mtime = 2020-01-01T00:00:00Z
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| RANGE-01 | `size>100` | big.txt | no match | no match | ❌ known |
| RANGE-02 | `size<100` | ancient.txt, small.txt | no match | no match | ❌ known |
| RANGE-03 | `mtime>"2021-01-01T00:00:00Z"` | big.txt, small.txt | big.txt, small.txt | big.txt, small.txt | ✅ |
| RANGE-04 | `mtime<"2021-01-01T00:00:00Z"` | ancient.txt | ancient.txt | ancient.txt | ✅ |
| RANGE-05 | `Mtime:"today"` | big.txt, small.txt | big.txt, small.txt | big.txt, small.txt | ✅ |
| RANGE-06 | `Mtime:"yesterday"` | no match | no match | no match | ✅ |
| RANGE-07 | `mtime>2021` | no match | no match | no match | ✅ |
| RANGE-08 | `name>100` | no match | no match | no match | ✅ |
### scope
Fixtures:
- `parent`, folder
- `child.pdf`, Path = ./parent/child.pdf
- `outside.txt`
- `elsewhere.txt`
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| SCOPE-01 | `*` | child.pdf, elsewhere.txt, outside.txt, parent | child.pdf, elsewhere.txt, outside.txt, parent | child.pdf, elsewhere.txt, outside.txt, parent | ✅ |
| SCOPE-02 | `*` in 1$1!1 | child.pdf, outside.txt, parent | child.pdf, outside.txt, parent | child.pdf, outside.txt, parent | ✅ |
| SCOPE-03 | `*` in 1$1!1 under ./parent | child.pdf, parent | child.pdf, parent | child.pdf, parent | ✅ |
| SCOPE-04 | `*` in 1$1!1 under ./parent/child.pdf | child.pdf | child.pdf | child.pdf | ✅ |
| SCOPE-05 | `name:"*elsewhere*"` in 1$1!1 | no match | no match | no match | ✅ |
### invalid
Fixtures:
- `alpha.txt`
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| INVALID-01 | `AND mediatype:document` | bad request | bad request | bad request | ✅ |
| INVALID-02 | `mediatype:document AND` | alpha.txt | alpha.txt | alpha.txt | ✅ |
## Operations
### delete
Fixtures:
- `parent`, ID = 1$1!2, folder
- `child.pdf`, ID = 1$1!3, Path = ./parent/child.pdf
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| DELETE-01 | takes the resource out of the results, then `name:"*parent*"` | parent | parent | parent | ✅ |
| DELETE-01 | takes the resource out of the results, then `name:"*child*"` | no match | no match | no match | ✅ |
| DELETE-02 | takes the descendants along, then `name:"*parent*"` | no match | no match | no match | ✅ |
| DELETE-02 | takes the descendants along, then `name:"*child*"` | no match | no match | no match | ✅ |
| DELETE-03 | leaves the resource in the index, then `DocCount()` | 2 | 2 | 1 | ❌ known |
| DELETE-04 | takes a resource out that was just written, then `name:"*fresh*"` | no match | no match | no match | ✅ |
### restore
Fixtures:
- `parent`, ID = 1$1!2, folder
- `child.pdf`, ID = 1$1!3, Path = ./parent/child.pdf
- `file.txt`, ID = 1$1!5, Path = ./.secret/file.txt, hidden
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| RESTORE-01 | brings the descendants back, then `name:"*parent*"` | parent | parent | parent | ✅ |
| RESTORE-01 | brings the descendants back, then `name:"*child*"` | child.pdf | child.pdf | child.pdf | ✅ |
| RESTORE-02 | leaves the hidden flag alone, then `hidden:true` | file.txt | no match | file.txt | ❌ known |
### purge
Fixtures:
- `parent`, ID = 1$1!2, folder
- `child.pdf`, ID = 1$1!3, Path = ./parent/child.pdf
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| PURGE-01 | removes one resource, then `name:"*parent*"` | parent | parent | parent | ✅ |
| PURGE-01 | removes one resource, then `name:"*child*"` | no match | no match | no match | ✅ |
| PURGE-01 | removes one resource, then `DocCount()` | 1 | 1 | 1 | ✅ |
| PURGE-02 | removes the tree, then `name:"*parent*"` | no match | no match | no match | ✅ |
| PURGE-02 | removes the tree, then `name:"*child*"` | no match | no match | no match | ✅ |
| PURGE-02 | removes the tree, then `DocCount()` | 0 | 0 | 0 | ✅ |
| PURGE-03 | takes only the deleted ones when it is told to, then `name:"*parent*"` | parent | parent | parent | ✅ |
| PURGE-03 | takes only the deleted ones when it is told to, then `name:"*child*"` | no match | no match | no match | ✅ |
### purgespace
Fixtures:
- `inSpace.txt`, ID = 1$1!4
- `elsewhere.txt`, ID = 2$2!4
- `bulk-0.txt`, ID = 1$1!bulk-0.txt
- `bulk-1.txt`, ID = 1$1!bulk-1.txt
- `bulk-2.txt`, ID = 1$1!bulk-2.txt
- `bulk-3.txt`, ID = 1$1!bulk-3.txt
- `bulk-4.txt`, ID = 1$1!bulk-4.txt
- `bulk-5.txt`, ID = 1$1!bulk-5.txt
- `bulk-6.txt`, ID = 1$1!bulk-6.txt
- `bulk-7.txt`, ID = 1$1!bulk-7.txt
- ... and 52 more of the same
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| PURGESPACE-01 | leaves the other space alone, then `name:"*inSpace*"` | no match | no match | no match | ✅ |
| PURGESPACE-01 | leaves the other space alone, then `name:"*elsewhere*"` | elsewhere.txt | elsewhere.txt | elsewhere.txt | ✅ |
| PURGESPACE-02 | takes a space out that holds more than one round, then `name:"*bulk*"` | no match | no match | no match | ✅ |
| PURGESPACE-02 | takes a space out that holds more than one round, then `name:"*elsewhere*"` | elsewhere.txt | elsewhere.txt | elsewhere.txt | ✅ |
| PURGESPACE-02 | takes a space out that holds more than one round, then `DocCount()` | 1 | 1 | 1 | ✅ |
### move
Fixtures:
- `parent`, ID = 1$1!2, folder
- `child.pdf`, ID = 1$1!3, Path = ./parent/child.pdf
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| MOVE-01 | carries the descendants to the new path, then `path:"./my/newname/child.pdf"` | child.pdf | child.pdf | child.pdf | ✅ |
| MOVE-01 | carries the descendants to the new path, then `path:"./parent/child.pdf"` | no match | no match | no match | ✅ |
| MOVE-02 | through the trash and back leaves the flag behind, then `hidden:true` | no match | no match | no match | ✅ |
### rootscope
Fixtures:
- `target.txt`, ID = 1$1!3, Path = ./same/path.txt
- `twin.txt`, ID = 2$2!3, Path = ./same/path.txt
- `target.txt`, ID = 1$1!3, Path = ./same/path.txt, deleted
- `twin.txt`, ID = 2$2!3, Path = ./same/path.txt, deleted
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| ROOTSCOPE-01 | deletes only the one in the target root, then `name:"*target*"` | no match | no match | no match | ✅ |
| ROOTSCOPE-01 | deletes only the one in the target root, then `name:"*twin*"` | twin.txt | twin.txt | twin.txt | ✅ |
| ROOTSCOPE-02 | restores only the one in the target root, then `name:"*target*"` | target.txt | target.txt | target.txt | ✅ |
| ROOTSCOPE-02 | restores only the one in the target root, then `name:"*twin*"` | no match | no match | no match | ✅ |
| ROOTSCOPE-03 | moves only the one in the target root, then `path:"./moved.txt"` | moved.txt | moved.txt | moved.txt | ✅ |
| ROOTSCOPE-03 | moves only the one in the target root, then `path:"./same/path.txt"` | twin.txt | twin.txt | twin.txt | ✅ |
### casepath
Fixtures:
- `Documents`, ID = 1$1!2, folder
- `Picture.jpg`, ID = 1$1!3, MimeType = image/jpeg, Path = ./Documents/Picture.jpg
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| CASEPATH-01 | takes the descendants along when deleting, then `path:"./Documents"` | no match | no match | Documents, Picture.jpg | ❌ known |
| CASEPATH-02 | takes the descendants along when moving, then `path:"./Other Documents"` | Other Documents, Picture.jpg | Other Documents | no match | ❌ known |
| CASEPATH-02 | takes the descendants along when moving, then `path:"./Documents"` | no match | no match | Documents, Picture.jpg | ❌ known |
| CASEPATH-03 | reaches the descendants when purging, then `path:"./Documents"` | no match | no match | Documents, Picture.jpg | ❌ known |
| CASEPATH-03 | reaches the descendants when purging, then `DocCount()` | 0 | 0 | 2 | ❌ known |
### hidden
Fixtures:
- `parent`, ID = 1$1!2, folder
- `child.pdf`, ID = 1$1!3, Path = ./parent/child.pdf
- `parent`, ID = 1$1!2, folder, Path = ./.trash/parent, hidden
- `child.pdf`, ID = 1$1!3, Path = ./.trash/parent/child.pdf, hidden
- `parent`, ID = 1$1!2, folder, Path = ./.parent, hidden
- `child.pdf`, ID = 1$1!3, Path = ./.parent/child.pdf, hidden
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| HIDDEN-01 | follows a move into a dot folder, then `hidden:true` | child.pdf, parent | no match | child.pdf, parent | ❌ known |
| HIDDEN-02 | follows a move into a plain folder, then `hidden:true` | no match | no match | no match | ✅ |
| HIDDEN-03 | follows a move renamed with a leading dot, then `hidden:true` | .parent, child.pdf | no match | .parent, child.pdf | ❌ known |
| HIDDEN-04 | follows a move out of a dot folder, then `hidden:true` | no match | no match | no match | ✅ |
| HIDDEN-05 | follows a move renamed without the leading dot, then `hidden:true` | no match | no match | no match | ✅ |
| HIDDEN-06 | follows a move within the same dot folder, then `hidden:true` | child.pdf, moved | no match | child.pdf, moved | ❌ known |
### upsert
Fixtures:
- `parent`, ID = 1$1!2, folder
- `child.pdf`, ID = 1$1!3, Path = ./parent/child.pdf
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| UPSERT-01 | replaces the resource it already knows, then `name:"*parent*"` | parent | parent | parent | ✅ |
| UPSERT-01 | replaces the resource it already knows, then `name:"*child*"` | no match | no match | no match | ✅ |
| UPSERT-01 | replaces the resource it already knows, then `name:"*renamed*"` | renamed.pdf | renamed.pdf | renamed.pdf | ✅ |
| UPSERT-01 | replaces the resource it already knows, then `DocCount()` | 2 | 2 | 2 | ✅ |
### idempotency
Fixtures:
- `parent`, ID = 1$1!2, folder
- `child.pdf`, ID = 1$1!3, Path = ./parent/child.pdf
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| IDEMPOTENCY-01 | deleting the same resource twice is not an error, then `name:"*parent*"` | parent | parent | parent | ✅ |
| IDEMPOTENCY-01 | deleting the same resource twice is not an error, then `name:"*child*"` | no match | no match | no match | ✅ |
| IDEMPOTENCY-02 | deleting a resource the index does not have reports it, then `name:"*parent*"` | parent | parent | parent | ✅ |
| IDEMPOTENCY-02 | deleting a resource the index does not have reports it, then `name:"*child*"` | child.pdf | child.pdf | child.pdf | ✅ |
| IDEMPOTENCY-03 | restoring a resource that was never deleted leaves it alone, then `name:"*parent*"` | parent | parent | parent | ✅ |
| IDEMPOTENCY-03 | restoring a resource that was never deleted leaves it alone, then `name:"*child*"` | child.pdf | child.pdf | child.pdf | ✅ |
| IDEMPOTENCY-04 | purging the same resource twice reports the second one, then `name:"*parent*"` | parent | parent | parent | ✅ |
| IDEMPOTENCY-04 | purging the same resource twice reports the second one, then `name:"*child*"` | no match | no match | no match | ✅ |
| IDEMPOTENCY-04 | purging the same resource twice reports the second one, then `DocCount()` | 1 | 1 | 1 | ✅ |
| IDEMPOTENCY-05 | purging a resource the index does not have reports it, then `name:"*parent*"` | parent | parent | parent | ✅ |
| IDEMPOTENCY-05 | purging a resource the index does not have reports it, then `name:"*child*"` | child.pdf | child.pdf | child.pdf | ✅ |
| IDEMPOTENCY-05 | purging a resource the index does not have reports it, then `DocCount()` | 2 | 2 | 2 | ✅ |
| IDEMPOTENCY-06 | moving a resource onto its own path leaves it where it is, then `path:"./parent/child.pdf"` | child.pdf | child.pdf | child.pdf | ✅ |
| IDEMPOTENCY-06 | moving a resource onto its own path leaves it where it is, then `name:"*parent*"` | parent | parent | parent | ✅ |
| IDEMPOTENCY-07 | purging a whole space that holds nothing is not an error, then `name:"*parent*"` | parent | parent | parent | ✅ |
| IDEMPOTENCY-07 | purging a whole space that holds nothing is not an error, then `name:"*child*"` | child.pdf | child.pdf | child.pdf | ✅ |
| IDEMPOTENCY-07 | purging a whole space that holds nothing is not an error, then `DocCount()` | 2 | 2 | 2 | ✅ |
### batch
Fixtures:
- `parent`, ID = 1$1!2, folder
- `child.pdf`, ID = 1$1!3, Path = ./parent/child.pdf
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| BATCH-01 | holds what it was given until it is pushed, then `name:"*added*"` | no match | no match | no match | ✅ |
| BATCH-02 | writes what it was given once it is pushed, then `name:"*added*"` | added.pdf | added.pdf | added.pdf | ✅ |
| BATCH-02 | writes what it was given once it is pushed, then `DocCount()` | 3 | 3 | 3 | ✅ |
| BATCH-03 | takes a resource out the same way a delete does, then `name:"*parent*"` | parent | parent | parent | ✅ |
| BATCH-03 | takes a resource out the same way a delete does, then `name:"*child*"` | no match | no match | no match | ✅ |
| BATCH-04 | moves a resource the same way a move does, then `path:"./my/newname/child.pdf"` | child.pdf | child.pdf | child.pdf | ✅ |
| BATCH-04 | moves a resource the same way a move does, then `path:"./parent/child.pdf"` | no match | no match | no match | ✅ |
| BATCH-05 | keeps what another batch holds out of its push, then `name:"*added*"` | added.pdf | added.pdf | added.pdf | ✅ |
| BATCH-05 | keeps what another batch holds out of its push, then `name:"*other*"` | no match | no match | no match | ✅ |
## Response
### entity
Fixtures:
- `parent`, ID = 1$1!2, folder
- `bar.pdf`, ID = 1$1!3, MimeType = application/pdf, Path = ./parent/bar.pdf, Size = 1234
- `notes.txt`, ID = 1$1!4, Content = "foo bar baz"
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| ENTITY-01 | `name:"bar.pdf"` reads `Ref.Path` | ./parent/bar.pdf | ./parent/bar.pdf | ./parent/bar.pdf | ✅ |
| ENTITY-02 | `name:"bar.pdf"` reads `Name` | bar.pdf | bar.pdf | bar.pdf | ✅ |
| ENTITY-03 | `name:"bar.pdf"` reads `Id` | 1$1!3 | 1$1!3 | 1$1!3 | ✅ |
| ENTITY-04 | `name:"bar.pdf"` reads `ParentId` | 1$1!2 | 1$1!2 | 1$1!2 | ✅ |
| ENTITY-05 | `name:"bar.pdf"` reads `Ref.ResourceId` | 1$1!1 | 1$1!1 | 1$1!1 | ✅ |
| ENTITY-06 | `name:"bar.pdf"` reads `Size` | 1234 | 1234 | 1234 | ✅ |
| ENTITY-07 | `name:"bar.pdf"` reads `Type` | 1 | 1 | 1 | ✅ |
| ENTITY-08 | `name:"bar.pdf"` reads `MimeType` | application/pdf | application/pdf | application/pdf | ✅ |
| ENTITY-09 | `name:"bar.pdf"` reads `Deleted` | false | false | false | ✅ |
| ENTITY-10 | `name:"bar.pdf"` reads `Score` | above zero | above zero | above zero | ✅ |
| ENTITY-11 | `path:"./parent"` reads `TotalMatches` | 2 | 1 | 2 | ❌ known |
| ENTITY-12 | `name:"*notes*"` reads `Highlights` | "" | "" | "" | ✅ |
| ENTITY-13 | `content:bar` reads `Highlights` | foo <mark>bar</mark> baz | foo <mark>bar</mark> baz | foo <mark>bar</mark> baz | ✅ |
| ENTITY-14 | moved to another parent, then `name:"newname"` reads `ParentId` | 1$1!9 | 1$1!9 | 1$1!9 | ✅ |
| ENTITY-15 | moved to another parent, then `name:"bar.pdf"` reads `ParentId` | 1$1!2 | 1$1!2 | 1$1!2 | ✅ |
| ENTITY-16 | moved to another parent, then `name:"bar.pdf"` reads `Ref.Path` | ./somewher.../bar.pdf | ./somewher.../bar.pdf | ./somewher.../bar.pdf | ✅ |
### metadata
Fixtures:
- `some_song.mp3`, ID = 1$1!5, MimeType = audio/mpeg
- `team.jpg`, ID = 1$1!6, MimeType = image/jpeg
| Case | Query | expected | bleve | OpenSearch | same? |
|---|---|---|---|---|---|
| METADATA-01 | `*song*` reads `Audio` | all 16 fields unchanged | all 16 fields unchanged | all 16 fields unchanged | ✅ |
| METADATA-02 | `*team*` reads `Location` | all 3 fields unchanged | all 3 fields unchanged | all 3 fields unchanged | ✅ |
| METADATA-03 | `*team*` reads `Audio` | none | none | none | ✅ |
+194
View File
@@ -0,0 +1,194 @@
package parity
import (
"context"
"fmt"
"strings"
bleveSearch "github.com/blevesearch/bleve/v2"
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/types"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/opencloud-eu/opencloud/pkg/log"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
bleveEngine "github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch"
bleveQuery "github.com/opencloud-eu/opencloud/services/search/pkg/query/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
var engineNames = []string{"bleve", "opensearch"}
type testEngine struct {
name string
backend search.Engine
settle func()
unavailable string
}
// newEngines builds one backend per engine over the same fixtures. Call it
// from a BeforeAll: the cleanups it defers run when the container ends.
func newEngines(index string, fixtures []search.Resource) []testEngine {
GinkgoHelper()
return []testEngine{
newBleve(fixtures),
newOpenSearch(index, fixtures),
}
}
// newEngine builds just the one engine a spec is about; its cleanup runs when
// that spec ends.
func newEngine(name, index string, fixtures []search.Resource) testEngine {
GinkgoHelper()
switch name {
case "bleve":
return newBleve(fixtures)
case "opensearch":
return newOpenSearch(index, fixtures)
}
Fail("no engine named " + name)
return testEngine{}
}
func engineNamed(engines []testEngine, name string) testEngine {
GinkgoHelper()
for _, e := range engines {
if e.name == name {
return e
}
}
Fail("no engine named " + name)
return testEngine{}
}
func newBleve(fixtures []search.Resource) testEngine {
GinkgoHelper()
mapping, err := bleveEngine.NewMapping()
Expect(err).NotTo(HaveOccurred(), "failed to build the bleve mapping")
idx, err := bleveSearch.NewMemOnly(mapping)
Expect(err).NotTo(HaveOccurred(), "failed to create the bleve index")
DeferCleanup(func() { _ = idx.Close() })
backend := bleveEngine.NewBackend(idx, bleveQuery.DefaultCreator, log.Logger{})
load(backend, fixtures)
return testEngine{name: "bleve", backend: backend, settle: func() {}}
}
func newOpenSearch(index string, fixtures []search.Resource) testEngine {
GinkgoHelper()
tc := opensearchtest.NewDefaultTestClient(GinkgoTB(), openSearchClient)
if err := tc.IndicesReset(context.Background(), []string{index}); err != nil {
return testEngine{name: "opensearch", unavailable: err.Error()}
}
backend, err := opensearch.NewBackend(index, tc.Client())
if err != nil {
return testEngine{name: "opensearch", unavailable: err.Error()}
}
DeferCleanup(func() {
Expect(tc.IndicesDelete(context.Background(), []string{index})).To(Succeed())
})
// every write waits for the next refresh (Refresh: wait_for), which is a
// second apart by default; the test index can refresh far more often
_, err = tc.Client().Indices.Settings.Put(context.Background(), opensearchgoAPI.SettingsPutReq{
Indices: []string{index},
Body: strings.NewReader(`{"index": {"refresh_interval": "50ms"}}`),
})
Expect(err).NotTo(HaveOccurred(), "failed to speed up the refresh of %s", index)
settle := func() { tc.Require.IndicesRefresh([]string{index}, nil) }
load(backend, fixtures)
settle()
return testEngine{name: "opensearch", backend: backend, settle: settle}
}
// load writes the fixtures the way the service does, in one batch.
func load(backend search.Engine, fixtures []search.Resource) {
GinkgoHelper()
if len(fixtures) == 0 {
return
}
batch, err := backend.NewBatch(len(fixtures) + 1)
Expect(err).NotTo(HaveOccurred(), "failed to open a batch for the fixtures")
for _, doc := range fixtures {
Expect(batch.Upsert(doc.ID, doc)).To(Succeed(), "upsert %s", doc.ID)
}
Expect(batch.Push()).To(Succeed(), "failed to write the fixtures")
}
func badRequestAnswer(err error) []string {
if err == nil {
return []string{"no error"}
}
return []string{"bad request"}
}
func reads(read func(*searchMessage.Match) string) func(*searchService.SearchIndexResponse) []string {
return readsMany(func(m *searchMessage.Match) []string { return []string{read(m)} })
}
func readsMany(read func(*searchMessage.Match) []string) func(*searchService.SearchIndexResponse) []string {
return func(resp *searchService.SearchIndexResponse) []string {
if len(resp.Matches) != 1 {
return []string{fmt.Sprintf("%d matches", len(resp.Matches))}
}
return read(resp.Matches[0])
}
}
func resourceID(id *searchMessage.ResourceID) string {
return storagespace.FormatResourceID(&sprovider.ResourceId{
StorageId: id.GetStorageId(),
SpaceId: id.GetSpaceId(),
OpaqueId: id.GetOpaqueId(),
})
}
// ask runs a query and returns the matched names; an engine error is the
// answer "error", so a query the engine rejects still shows in the matrix.
func ask(e search.Engine, request *searchService.SearchIndexRequest) ([]string, error) {
resp, err := e.Search(context.Background(), request)
if err != nil {
return []string{"error"}, err
}
names := make([]string, 0, len(resp.Matches))
for _, m := range resp.Matches {
names = append(names, m.Entity.GetName())
}
return names, nil
}
// matchNames is ConsistOf for a wanted list, spelled out for the empty case so
// a "no match" expectation reads as such in the failure.
func matchNames(want []string) types.GomegaMatcher {
if len(want) == 0 {
return BeEmpty()
}
return ConsistOf(want)
}
+108
View File
@@ -0,0 +1,108 @@
package parity
import (
"fmt"
"slices"
"strings"
"time"
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
var fixtureLongName = strings.Repeat("a", 260) + "needle.txt"
var fixtureNow = time.Now().UTC()
type fixtureOption func(*search.Resource)
func withPath(path string) fixtureOption { return func(r *search.Resource) { r.Path = path } }
func withMime(mime string) fixtureOption { return func(r *search.Resource) { r.MimeType = mime } }
func withTitle(t string) fixtureOption { return func(r *search.Resource) { r.Title = t } }
func withContent(c string) fixtureOption { return func(r *search.Resource) { r.Content = c } }
func withSize(s uint64) fixtureOption { return func(r *search.Resource) { r.Size = s } }
func withMtime(m string) fixtureOption { return func(r *search.Resource) { r.Mtime = m } }
func withID(id string) fixtureOption { return func(r *search.Resource) { r.ID = id } }
func withParent(id string) fixtureOption { return func(r *search.Resource) { r.ParentID = id } }
func withRoot(id string) fixtureOption { return func(r *search.Resource) { r.RootID = id } }
func isHidden() fixtureOption { return func(r *search.Resource) { r.Hidden = true } }
func isDeleted() fixtureOption { return func(r *search.Resource) { r.Deleted = true } }
func withTags(tags ...string) fixtureOption {
return func(r *search.Resource) { r.Tags = tags }
}
func withFavorite(userID string) fixtureOption {
return func(r *search.Resource) { r.Favorites = []string{userID} }
}
func withAudio(audio *libregraph.Audio) fixtureOption {
return func(r *search.Resource) { r.Audio = audio }
}
func withLocation(location *libregraph.GeoCoordinates) fixtureOption {
return func(r *search.Resource) { r.Location = location }
}
func fixtureDoc(name string, opts ...fixtureOption) search.Resource {
r := search.Resource{
ID: "1$1!" + name,
RootID: "1$1!1",
ParentID: "1$1!1",
Path: "./" + name,
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{
Name: name,
MimeType: "text/plain",
Mtime: fixtureNow.Format(time.RFC3339Nano),
Size: 1000,
},
}
for _, opt := range opts {
opt(&r)
}
return r
}
func fixtureFolder(name string, opts ...fixtureOption) search.Resource {
folder := []fixtureOption{withMime("httpd/unix-directory")}
r := fixtureDoc(name, append(folder, opts...)...)
r.Type = uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER)
return r
}
func fixtureBulk(count int) []search.Resource {
docs := make([]search.Resource, 0, count)
for i := range count {
docs = append(docs, fixtureDoc(fmt.Sprintf("bulk-%d.txt", i)))
}
return docs
}
func fixtureTree() (parent, child search.Resource) {
parent = fixtureFolder("parent", withID("1$1!2"))
child = fixtureDoc("child.pdf", withID("1$1!3"), withParent(parent.ID), withPath("./parent/child.pdf"))
return parent, child
}
func treeIsLeft(names ...string) []expectation {
left := func(name string) []string {
if slices.Contains(names, name) {
return []string{name}
}
return nil
}
return []expectation{
{`name:"*parent*"`, left("parent")},
{`name:"*child*"`, left("child.pdf")},
}
}
@@ -0,0 +1,112 @@
package parity
import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func batchLifecycle() lifecycleGroup {
parent, child := fixtureTree()
added := fixtureDoc("added.pdf", withID("1$1!7"), withParent(parent.ID), withPath("./parent/added.pdf"))
other := fixtureDoc("other.pdf", withID("1$1!8"), withParent(parent.ID), withPath("./parent/other.pdf"))
return lifecycleGroup{
name: "batch",
fixtures: []search.Resource{parent, child},
cases: []lifecycleCase{
{
id: 1, title: "holds what it was given until it is pushed",
do: func(e search.Engine) error {
batch, err := e.NewBatch(100)
if err != nil {
return err
}
return batch.Upsert(added.ID, added)
},
expect: []expectation{{`name:"*added*"`, nil}},
},
{
id: 2, title: "writes what it was given once it is pushed",
do: func(e search.Engine) error {
batch, err := e.NewBatch(100)
if err != nil {
return err
}
if err := batch.Upsert(added.ID, added); err != nil {
return err
}
return batch.Push()
},
expect: []expectation{{`name:"*added*"`, []string{"added.pdf"}}},
wantDocCount: conversions.ToPointer(uint64(3)),
},
{
id: 3, title: "takes a resource out the same way a delete does",
do: func(e search.Engine) error {
batch, err := e.NewBatch(100)
if err != nil {
return err
}
if err := batch.Delete(child.ID); err != nil {
return err
}
return batch.Push()
},
expect: treeIsLeft("parent"),
},
{
id: 4, title: "moves a resource the same way a move does",
do: func(e search.Engine) error {
batch, err := e.NewBatch(100)
if err != nil {
return err
}
if err := batch.Move(parent.ID, parent.ParentID, "./my/newname"); err != nil {
return err
}
return batch.Push()
},
expect: []expectation{
{`path:"./my/newname/child.pdf"`, []string{"child.pdf"}},
{`path:"./parent/child.pdf"`, nil},
},
},
{
id: 5, title: "keeps what another batch holds out of its push",
do: func(e search.Engine) error {
first, err := e.NewBatch(100)
if err != nil {
return err
}
second, err := e.NewBatch(100)
if err != nil {
return err
}
if err := first.Upsert(added.ID, added); err != nil {
return err
}
if err := second.Upsert(other.ID, other); err != nil {
return err
}
return first.Push()
},
expect: []expectation{
{`name:"*added*"`, []string{"added.pdf"}},
{`name:"*other*"`, nil},
},
},
},
}
}
@@ -0,0 +1,61 @@
package parity
import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func casePathLifecycle() lifecycleGroup {
folder := fixtureFolder("Documents", withID("1$1!2"), withPath("./Documents"))
picture := fixtureDoc("Picture.jpg",
withID("1$1!3"),
withParent(folder.ID),
withMime("image/jpeg"),
withPath("./Documents/Picture.jpg"),
)
left := func(names ...string) []expectation {
return []expectation{
{`path:"./Documents"`, names},
}
}
return lifecycleGroup{
name: "casepath",
fixtures: []search.Resource{folder, picture},
cases: []lifecycleCase{
{
id: 1, title: "takes the descendants along when deleting",
do: func(e search.Engine) error { return e.Delete(folder.ID) },
expect: left(),
engineOverrides: map[string]lifecycleOverride{
"opensearch": {expect: map[string][]string{`path:"./Documents"`: {"Documents", "Picture.jpg"}}},
},
},
{
id: 2, title: "takes the descendants along when moving",
do: func(e search.Engine) error { return e.Move(folder.ID, folder.ParentID, "./Other Documents") },
expect: []expectation{
{`path:"./Other Documents"`, []string{"Other Documents", "Picture.jpg"}},
{`path:"./Documents"`, nil},
},
engineOverrides: map[string]lifecycleOverride{
"bleve": {expect: map[string][]string{`path:"./Other Documents"`: {"Other Documents"}}},
"opensearch": {expect: map[string][]string{`path:"./Other Documents"`: {}, `path:"./Documents"`: {"Documents", "Picture.jpg"}}},
},
},
{
id: 3, title: "reaches the descendants when purging",
do: func(e search.Engine) error { return e.Purge(folder.ID, false) },
expect: left(),
wantDocCount: conversions.ToPointer(uint64(0)),
engineOverrides: map[string]lifecycleOverride{
"opensearch": {
expect: map[string][]string{`path:"./Documents"`: {"Documents", "Picture.jpg"}},
wantDocCount: conversions.ToPointer(uint64(2)),
},
},
},
},
}
}
@@ -0,0 +1,47 @@
package parity
import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func deleteLifecycle() lifecycleGroup {
parent, child := fixtureTree()
return lifecycleGroup{
name: "delete",
fixtures: []search.Resource{parent, child},
cases: []lifecycleCase{
{
id: 1, title: "takes the resource out of the results",
do: func(e search.Engine) error { return e.Delete(child.ID) },
expect: treeIsLeft("parent"),
},
{
id: 2, title: "takes the descendants along",
do: func(e search.Engine) error { return e.Delete(parent.ID) },
expect: treeIsLeft(),
},
{
id: 3, title: "leaves the resource in the index",
do: func(e search.Engine) error { return e.Delete(child.ID) },
wantDocCount: conversions.ToPointer(uint64(2)),
engineOverrides: map[string]lifecycleOverride{
"opensearch": {wantDocCount: conversions.ToPointer(uint64(1))},
},
},
{
id: 4, title: "takes a resource out that was just written",
do: func(e search.Engine) error {
fresh := fixtureDoc("fresh.txt", withID("1$1!8"))
if err := e.Upsert(fresh.ID, fresh); err != nil {
return err
}
return e.Delete(fresh.ID)
},
expect: []expectation{{`name:"*fresh*"`, nil}},
},
},
}
}
@@ -0,0 +1,54 @@
package parity
import (
"path"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func hiddenLifecycle() lifecycleGroup {
group := lifecycleGroup{name: "hidden"}
for _, move := range []struct {
id int
title string
from string
target string
hidden bool
}{
{1, "into a dot folder", "./parent", "./.trash/parent", true},
{2, "into a plain folder", "./parent", "./archive/parent", false},
{3, "renamed with a leading dot", "./parent", "./.parent", true},
{4, "out of a dot folder", "./.trash/parent", "./archive/parent", false},
{5, "renamed without the leading dot", "./.parent", "./parent", false},
{6, "within the same dot folder", "./.trash/parent", "./.trash/moved", true},
} {
parent, child := fixtureTree()
parent.Path = move.from
parent.Hidden = search.IsHidden(move.from)
child.Path = move.from + "/child.pdf"
child.Hidden = parent.Hidden
var (
hidden []string
overrides map[string]lifecycleOverride
)
if move.hidden {
hidden = []string{path.Base(move.target), "child.pdf"}
// bleve does not answer hidden:true at all today
overrides = map[string]lifecycleOverride{"bleve": {expect: map[string][]string{`hidden:true`: {}}}}
}
group.cases = append(group.cases, lifecycleCase{
id: move.id, title: "follows a move " + move.title,
fixtures: []search.Resource{parent, child},
do: func(e search.Engine) error {
return e.Move(parent.ID, parent.ParentID, move.target)
},
expect: []expectation{{`hidden:true`, hidden}},
engineOverrides: overrides,
})
}
return group
}
@@ -0,0 +1,76 @@
package parity
import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func idempotencyLifecycle() lifecycleGroup {
parent, child := fixtureTree()
missing := "1$1!gone"
return lifecycleGroup{
name: "idempotency",
fixtures: []search.Resource{parent, child},
cases: []lifecycleCase{
{
id: 1, title: "deleting the same resource twice is not an error",
do: func(e search.Engine) error {
if err := e.Delete(child.ID); err != nil {
return err
}
return e.Delete(child.ID)
},
expect: treeIsLeft("parent"),
},
{
id: 2, title: "deleting a resource the index does not have reports it",
do: func(e search.Engine) error { return e.Delete(missing) },
expect: treeIsLeft("parent", "child.pdf"),
wantErr: true,
},
{
id: 3, title: "restoring a resource that was never deleted leaves it alone",
do: func(e search.Engine) error { return e.Restore(child.ID) },
expect: treeIsLeft("parent", "child.pdf"),
},
{
id: 4, title: "purging the same resource twice reports the second one",
do: func(e search.Engine) error {
if err := e.Purge(child.ID, false); err != nil {
return err
}
return e.Purge(child.ID, false)
},
expect: treeIsLeft("parent"),
wantDocCount: conversions.ToPointer(uint64(1)),
wantErr: true,
},
{
id: 5, title: "purging a resource the index does not have reports it",
do: func(e search.Engine) error { return e.Purge(missing, false) },
expect: treeIsLeft("parent", "child.pdf"),
wantDocCount: conversions.ToPointer(uint64(2)),
wantErr: true,
},
{
id: 6, title: "moving a resource onto its own path leaves it where it is",
do: func(e search.Engine) error {
return e.Move(parent.ID, parent.ParentID, parent.Path)
},
expect: []expectation{
{`path:"./parent/child.pdf"`, []string{"child.pdf"}},
{`name:"*parent*"`, []string{"parent"}},
},
},
{
id: 7, title: "purging a whole space that holds nothing is not an error",
do: func(e search.Engine) error { return e.PurgeSpace("9$9!9") },
expect: treeIsLeft("parent", "child.pdf"),
wantDocCount: conversions.ToPointer(uint64(2)),
},
},
}
}
@@ -0,0 +1,37 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func moveLifecycle() lifecycleGroup {
parent, child := fixtureTree()
return lifecycleGroup{
name: "move",
fixtures: []search.Resource{parent, child},
cases: []lifecycleCase{
{
id: 1, title: "carries the descendants to the new path",
do: func(e search.Engine) error {
return e.Move(parent.ID, parent.ParentID, "./my/newname")
},
expect: []expectation{
{`path:"./my/newname/child.pdf"`, []string{"child.pdf"}},
{`path:"./parent/child.pdf"`, nil},
},
},
{
id: 2, title: "through the trash and back leaves the flag behind",
do: func(e search.Engine) error {
if err := e.Move(parent.ID, parent.ParentID, "./.trash/parent"); err != nil {
return err
}
return e.Move(parent.ID, parent.ParentID, "./parent")
},
expect: []expectation{{`hidden:true`, nil}},
},
},
}
}
@@ -0,0 +1,40 @@
package parity
import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func purgeLifecycle() lifecycleGroup {
parent, child := fixtureTree()
return lifecycleGroup{
name: "purge",
fixtures: []search.Resource{parent, child},
cases: []lifecycleCase{
{
id: 1, title: "removes one resource",
do: func(e search.Engine) error { return e.Purge(child.ID, false) },
expect: treeIsLeft("parent"),
wantDocCount: conversions.ToPointer(uint64(1)),
},
{
id: 2, title: "removes the tree",
do: func(e search.Engine) error { return e.Purge(parent.ID, false) },
expect: treeIsLeft(),
wantDocCount: conversions.ToPointer(uint64(0)),
},
{
id: 3, title: "takes only the deleted ones when it is told to",
do: func(e search.Engine) error {
if err := e.Delete(child.ID); err != nil {
return err
}
return e.Purge(parent.ID, true)
},
expect: treeIsLeft("parent"),
},
},
}
}
@@ -0,0 +1,36 @@
package parity
import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func purgeSpaceLifecycle() lifecycleGroup {
inSpace := fixtureDoc("inSpace.txt", withID("1$1!4"))
elsewhere := fixtureDoc("elsewhere.txt", withID("2$2!4"), withRoot("2$2!1"))
return lifecycleGroup{
name: "purgespace",
fixtures: []search.Resource{inSpace, elsewhere},
cases: []lifecycleCase{
{
id: 1, title: "leaves the other space alone",
do: func(e search.Engine) error { return e.PurgeSpace(inSpace.RootID) },
expect: []expectation{
{`name:"*inSpace*"`, nil},
{`name:"*elsewhere*"`, []string{"elsewhere.txt"}},
},
},
{
id: 2, title: "takes a space out that holds more than one round",
fixtures: append(fixtureBulk(60), elsewhere),
do: func(e search.Engine) error { return e.PurgeSpace(inSpace.RootID) },
expect: []expectation{
{`name:"*bulk*"`, nil},
{`name:"*elsewhere*"`, []string{"elsewhere.txt"}},
},
wantDocCount: conversions.ToPointer(uint64(1)),
},
},
}
}
@@ -0,0 +1,41 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func restoreLifecycle() lifecycleGroup {
parent, child := fixtureTree()
secret := fixtureDoc("file.txt", withID("1$1!5"), withPath("./.secret/file.txt"), isHidden())
return lifecycleGroup{
name: "restore",
fixtures: []search.Resource{parent, child},
cases: []lifecycleCase{
{
id: 1, title: "brings the descendants back",
do: func(e search.Engine) error {
if err := e.Delete(parent.ID); err != nil {
return err
}
return e.Restore(parent.ID)
},
expect: treeIsLeft("parent", "child.pdf"),
},
{
id: 2, title: "leaves the hidden flag alone",
fixtures: []search.Resource{secret},
do: func(e search.Engine) error {
if err := e.Delete(secret.ID); err != nil {
return err
}
return e.Restore(secret.ID)
},
expect: []expectation{{`hidden:true`, []string{"file.txt"}}},
engineOverrides: map[string]lifecycleOverride{"bleve": {expect: map[string][]string{`hidden:true`: {}}}},
},
},
}
}
@@ -0,0 +1,50 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func rootScopeLifecycle() lifecycleGroup {
const shared = "./same/path.txt"
target := fixtureDoc("target.txt", withID("1$1!3"), withPath(shared))
twin := fixtureDoc("twin.txt", withID("2$2!3"), withRoot("2$2!1"), withParent("2$2!2"), withPath(shared))
deleted := func(r search.Resource) search.Resource {
r.Deleted = true
return r
}
return lifecycleGroup{
name: "rootscope",
fixtures: []search.Resource{target, twin},
cases: []lifecycleCase{
{
id: 1, title: "deletes only the one in the target root",
do: func(e search.Engine) error { return e.Delete(target.ID) },
expect: []expectation{
{`name:"*target*"`, nil},
{`name:"*twin*"`, []string{"twin.txt"}},
},
},
{
id: 2, title: "restores only the one in the target root",
fixtures: []search.Resource{deleted(target), deleted(twin)},
do: func(e search.Engine) error { return e.Restore(target.ID) },
expect: []expectation{
{`name:"*target*"`, []string{"target.txt"}},
{`name:"*twin*"`, nil},
},
},
{
id: 3, title: "moves only the one in the target root",
do: func(e search.Engine) error { return e.Move(target.ID, target.ParentID, "./moved.txt") },
expect: []expectation{
{`path:"./moved.txt"`, []string{"moved.txt"}},
{`path:"` + shared + `"`, []string{"twin.txt"}},
},
},
},
}
}
@@ -0,0 +1,27 @@
package parity
import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func upsertLifecycle() lifecycleGroup {
parent, child := fixtureTree()
renamed := child
renamed.Name = "renamed.pdf"
renamed.Path = "./parent/renamed.pdf"
return lifecycleGroup{
name: "upsert",
fixtures: []search.Resource{parent, child},
cases: []lifecycleCase{
{
id: 1, title: "replaces the resource it already knows",
do: func(e search.Engine) error { return e.Upsert(renamed.ID, renamed) },
expect: append(treeIsLeft("parent"), expectation{`name:"*renamed*"`, []string{"renamed.pdf"}}),
wantDocCount: conversions.ToPointer(uint64(2)),
},
},
}
}
+457
View File
@@ -0,0 +1,457 @@
package parity
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/ginkgo/v2/types"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
const (
matrixFile = "README.md"
matrixEntry = "engine parity"
)
// matrixRow is one line of the README. It travels in a report entry, which
// crosses processes as JSON under ginkgo -p, hence the exported fields and
// the scope already rendered to a string.
type matrixRow struct {
Section string
Group string
ID string
Query string
Scope string
Limit int32
Reads string
Context string
Want []string
WantCount *int
WantBadRequest bool
// Overrides is what an engine is held to instead, rendered like expected
Overrides map[string]string
GroupAt, CaseAt, QueryAt int
}
func (r matrixRow) key() string {
return fmt.Sprintf("%s/%s/%s/%d", r.Group, r.ID, r.Query, r.QueryAt)
}
// matrixAnswer is what a spec attaches to its report: one engine's answer to
// one row. A skipped engine leaves the README alone.
type matrixAnswer struct {
Row matrixRow
Engine string
Answer []string
Skipped bool
}
func recordAnswer(row matrixRow, engine string, answer []string) {
AddReportEntry(matrixEntry, matrixAnswer{Row: row, Engine: engine, Answer: answer}, ReportEntryVisibilityNever)
}
func recordSkip(row matrixRow, engine string) {
AddReportEntry(matrixEntry, matrixAnswer{Row: row, Engine: engine, Skipped: true}, ReportEntryVisibilityNever)
}
// matrixPlanned is every row the spec tree announced while it was built, so
// the README is only written when each of them got an answer from every engine.
var matrixPlanned []matrixRow
func planRow(rows ...matrixRow) {
matrixPlanned = append(matrixPlanned, rows...)
}
type matrixResult struct {
matrixRow
answered map[string][]string
skipped map[string]bool
}
// matrixAnswerOf reads an entry back: in-process it still carries the value,
// from another process only its JSON.
func matrixAnswerOf(entry types.ReportEntry) (matrixAnswer, bool) {
if answer, ok := entry.GetRawValue().(matrixAnswer); ok {
return answer, true
}
var answer matrixAnswer
if err := json.Unmarshal([]byte(entry.Value.AsJSON), &answer); err != nil {
return matrixAnswer{}, false
}
return answer, true
}
func collectMatrix(report types.Report) []*matrixResult {
results := map[string]*matrixResult{}
for _, spec := range report.SpecReports {
for _, entry := range spec.ReportEntries {
if entry.Name != matrixEntry {
continue
}
answer, ok := matrixAnswerOf(entry)
if !ok {
continue
}
key := answer.Row.key()
result, known := results[key]
if !known {
result = &matrixResult{matrixRow: answer.Row, answered: map[string][]string{}, skipped: map[string]bool{}}
results[key] = result
}
if answer.Skipped {
result.skipped[answer.Engine] = true
continue
}
result.answered[answer.Engine] = answer.Answer
}
}
rows := make([]*matrixResult, 0, len(results))
for _, result := range results {
rows = append(rows, result)
}
return rows
}
func writeMatrix(report types.Report) {
rows := collectMatrix(report)
answered := map[string]*matrixResult{}
for _, row := range rows {
answered[row.key()] = row
}
var missing []string
for _, planned := range matrixPlanned {
result, ok := answered[planned.key()]
switch {
case !ok:
missing = append(missing, planned.ID+" "+planned.Query)
case len(result.skipped) > 0:
fmt.Fprintf(os.Stderr, "%s left alone, an engine was not reachable\n", matrixFile)
return
case len(result.answered) != len(engineNames):
missing = append(missing, planned.ID+" "+planned.Query)
}
}
if len(missing) > 0 {
fmt.Fprintf(os.Stderr, "%s left alone, no answer from every engine for: %s\n", matrixFile, strings.Join(missing, "; "))
return
}
sort.Slice(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
switch {
case a.GroupAt != b.GroupAt:
return a.GroupAt < b.GroupAt
case a.CaseAt != b.CaseAt:
return a.CaseAt < b.CaseAt
default:
return a.QueryAt < b.QueryAt
}
})
out := &strings.Builder{}
out.WriteString("# Engine parity\n\n")
out.WriteString("Written by the parity suite (`go test ./services/search/pkg/parity/`), do not edit.\n")
out.WriteString("Every case runs against bleve and OpenSearch. `same?` is ✅ when both answer as\n")
out.WriteString("expected, `❌ known` when an engine's divergence is documented in the case\n")
out.WriteString("(`engineOverrides`), `❌` when it is not.\n")
group, section := "", ""
for _, row := range rows {
if row.Section != section {
section = row.Section
if out.Len() > 0 {
out.WriteString("\n")
}
fmt.Fprintf(out, "## %s\n", section)
}
if row.Group != group {
group = row.Group
fmt.Fprintf(out, "\n### %s\n\n%s\n\n", group, matrixFixtures(group))
out.WriteString("| Case | Query | expected | bleve | OpenSearch | same? |\n")
out.WriteString("|---|---|---|---|---|---|\n")
}
query := "`" + shortenQuery(row.Query) + "`"
if row.Scope != "" {
query += " " + row.Scope
}
if row.Limit != 0 {
query = fmt.Sprintf("%s with a limit of %d", query, row.Limit)
}
if row.Reads != "" {
query += " reads `" + row.Reads + "`"
}
if row.Context != "" {
query = row.Context + ", then " + query
}
expected := matrixNames(row.Want)
switch {
case row.WantBadRequest:
expected = "bad request"
case row.WantCount != nil:
expected = fmt.Sprintf("%d items", *row.WantCount)
}
fmt.Fprintf(out, "| %s | %s | %s | %s | %s | %s |\n",
row.ID, query, expected,
matrixNames(row.answered["bleve"]), matrixNames(row.answered["opensearch"]), matrixVerdict(row))
}
if err := os.WriteFile(matrixFile, []byte(out.String()), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "failed to write %s: %v\n", matrixFile, err)
}
}
func matrixFixtures(group string) string {
var (
fixtures []search.Resource
withIDs bool
)
for _, g := range queryGroups() {
if g.name != group {
continue
}
fixtures = g.fixtures
for _, c := range g.cases {
withIDs = withIDs || strings.Contains(c.query, "id:")
}
}
for _, g := range lifecycleGroups() {
if g.name != group {
continue
}
withIDs = true
fixtures = g.fixtures
for _, c := range g.cases {
fixtures = append(fixtures, c.fixtures...)
}
}
for _, g := range responseGroups() {
if g.name != group {
continue
}
withIDs = true
fixtures = g.fixtures
}
if len(fixtures) == 0 {
return "Fixtures: none"
}
seen := map[string]bool{}
lines := []string{"Fixtures:", ""}
for _, f := range fixtures {
line := "- `" + shorten(f.Name) + "`"
if fields := fixtureFields(f, withIDs); fields != "" {
line += ", " + fields
}
if seen[line] {
continue
}
seen[line] = true
lines = append(lines, line)
}
if listed := len(lines) - 2; listed > 12 {
lines = append(lines[:12], fmt.Sprintf("- ... and %d more of the same", listed-10))
}
return strings.Join(lines, "\n")
}
func fixtureFields(f search.Resource, withID bool) string {
var fields []string
add := func(format string, args ...any) {
fields = append(fields, fmt.Sprintf(format, args...))
}
if withID {
add("ID = %s", f.ID)
}
if f.Type == uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER) {
add("folder")
} else if f.MimeType != "text/plain" {
add("MimeType = %s", f.MimeType)
}
if f.Path != "./"+f.Name {
add("Path = %s", f.Path)
}
if f.Title != "" {
add("Title = %q", f.Title)
}
if f.Content != "" {
add("Content = %q", f.Content)
}
if len(f.Tags) > 0 {
add("Tags = %s", strings.Join(f.Tags, ", "))
}
if len(f.Favorites) > 0 {
add("Favorites = %s", strings.Join(f.Favorites, ", "))
}
if f.Size != 1000 {
add("Size = %d", f.Size)
}
if !strings.HasPrefix(f.Mtime, fixtureNow.Format("2006-01-02")) {
add("Mtime = %s", f.Mtime)
}
if f.Hidden {
add("hidden")
}
if f.Deleted {
add("deleted")
}
return strings.Join(fields, ", ")
}
func matrixScope(ref *searchMessage.Reference) string {
if ref == nil {
return ""
}
space := storagespace.FormatResourceID(&sprovider.ResourceId{
StorageId: ref.GetResourceId().GetStorageId(),
SpaceId: ref.GetResourceId().GetSpaceId(),
OpaqueId: ref.GetResourceId().GetOpaqueId(),
})
if path := ref.GetPath(); path != "" {
return "in " + space + " under " + path
}
return "in " + space
}
func matrixNames(names []string) string {
if len(names) == 0 {
return "no match"
}
shortened := make([]string, 0, len(names))
for _, name := range names {
shortened = append(shortened, shorten(name))
}
sort.Strings(shortened)
return strings.Join(shortened, ", ")
}
func shorten(name string) string {
if len(name) <= 24 {
return name
}
return name[:10] + "..." + name[len(name)-8:]
}
func shortenQuery(q string) string {
if len(q) <= 64 {
return q
}
return q[:32] + "..." + q[len(q)-24:]
}
func matrixVerdict(row *matrixResult) string {
var off []string
known := true
for _, engine := range engineNames {
answer := matrixNames(row.answered[engine])
agrees := answer == matrixNames(row.Want)
switch {
case row.WantBadRequest:
agrees = answer == "bad request"
case row.WantCount != nil:
agrees = len(row.answered[engine]) == *row.WantCount
}
if agrees {
continue
}
off = append(off, engine)
if row.WantCount != nil {
answer = fmt.Sprintf("%d items", len(row.answered[engine]))
}
if expected, ok := row.Overrides[engine]; !ok || expected != answer {
known = false
}
}
switch {
case len(off) == 0:
return "✅"
case known:
return "❌ known"
default:
return "❌"
}
}
func (c lifecycleCase) matrixRows(group string, groupAt, caseAt int) []matrixRow {
rows := make([]matrixRow, 0, len(c.expect)+1)
for i, expect := range c.expect {
rows = append(rows, matrixRow{
Section: "Operations", Group: group, ID: c.label(group), Query: expect.query, Context: c.title, Want: expect.want, Overrides: renderOverrides(c.overridesFor(expect.query)),
GroupAt: groupAt, CaseAt: caseAt, QueryAt: i,
})
}
if c.wantDocCount != nil {
rows = append(rows, matrixRow{
Section: "Operations", Group: group, ID: c.label(group), Query: "DocCount()", Context: c.title,
Want: []string{fmt.Sprint(*c.wantDocCount)},
Overrides: renderOverrides(c.docCountOverrides()),
GroupAt: groupAt, CaseAt: caseAt, QueryAt: len(c.expect),
})
}
return rows
}
@@ -0,0 +1,48 @@
package parity
import (
"context"
"encoding/json"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
)
var (
// openSearchClient points every process at the one OpenSearch started by
// process 1; the container handle stays there.
openSearchClient config.EngineOpenSearchClient
stopOpenSearch func()
)
func TestEngineParity(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Search Engine Parity Suite")
}
var _ = SynchronizedBeforeSuite(func() []byte {
cfg, done, err := opensearchtest.SetupTests(context.Background())
Expect(err).NotTo(HaveOccurred(), "failed to set up the OpenSearch test container")
stopOpenSearch = done
client, err := json.Marshal(cfg.Engine.OpenSearch.Client)
Expect(err).NotTo(HaveOccurred())
return client
}, func(client []byte) {
Expect(json.Unmarshal(client, &openSearchClient)).To(Succeed())
})
var _ = SynchronizedAfterSuite(func() {}, func() {
if stopOpenSearch != nil {
stopOpenSearch()
}
})
var _ = ReportAfterSuite("engine parity matrix", func(report Report) {
writeMatrix(report)
})
+466
View File
@@ -0,0 +1,466 @@
package parity
import (
"context"
"fmt"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/types"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
type queryCase struct {
id int
query string
want []string
limit int32
wantCount *int
ref *searchMessage.Reference
wantBadRequest bool
// engineOverrides holds, per engine, what that engine answers today where
// it still differs from the expectation above. The spec asserts the
// override and the README marks the row as a known divergence. Once the
// engine answers as expected the override fails and has to go.
engineOverrides map[string]override
}
// override is one engine's current answer to a case, in the expectation's
// own terms.
type override struct {
want []string
wantCount *int
wantBadRequest bool
}
func (o override) matcher() types.GomegaMatcher {
switch {
case o.wantBadRequest:
return ConsistOf("bad request")
case o.wantCount != nil:
return HaveLen(*o.wantCount)
default:
return matchNames(o.want)
}
}
// rendered spells the override the way the README spells an expectation.
func (o override) rendered() string {
switch {
case o.wantBadRequest:
return "bad request"
case o.wantCount != nil:
return fmt.Sprintf("%d items", *o.wantCount)
default:
return matrixNames(o.want)
}
}
func renderOverrides(overrides map[string]override) map[string]string {
if len(overrides) == 0 {
return nil
}
out := make(map[string]string, len(overrides))
for engine, o := range overrides {
out[engine] = o.rendered()
}
return out
}
func (c queryCase) label(group string) string {
return fmt.Sprintf("%s-%02d", strings.ToUpper(group), c.id)
}
func (c queryCase) name(group string) string {
return c.label(group) + " " + c.query
}
type queryGroup struct {
name string
fixtures []search.Resource
cases []queryCase
}
func queryGroups() []queryGroup {
return []queryGroup{
nameGroup(),
extensionGroup(),
tagsGroup(),
titleGroup(),
contentGroup(),
favoritesGroup(),
mediatypeGroup(),
pathGroup(),
fieldsGroup(),
deletedGroup(),
visibilityGroup(),
booleanGroup(),
samenameGroup(),
stressGroup(),
everythingGroup(),
rangeGroup(),
scopeGroup(),
invalidGroup(),
}
}
type responseCase struct {
id int
do func(e search.Engine) error
after string
query string
reads string
read func(*searchService.SearchIndexResponse) []string
want []string
engineOverrides map[string]override
}
func (c responseCase) label(group string) string {
return fmt.Sprintf("%s-%02d", strings.ToUpper(group), c.id)
}
func (c responseCase) name(group string) string {
return c.label(group) + " " + c.reads
}
type responseGroup struct {
name string
fixtures []search.Resource
cases []responseCase
}
func responseGroups() []responseGroup {
return []responseGroup{
entityGroup(),
metadataGroup(),
}
}
type lifecycleCase struct {
id int
title string
do func(e search.Engine) error
expect []expectation
wantErr bool
fixtures []search.Resource
wantDocCount *uint64
// engineOverrides is the lifecycle spelling of queryCase.engineOverrides:
// a case has several expectations, so an override answers them by query.
engineOverrides map[string]lifecycleOverride
}
// lifecycleOverride is one engine's current answers to a lifecycle case.
type lifecycleOverride struct {
expect map[string][]string
wantDocCount *uint64
}
// overridesFor picks one expectation's overrides out of the case's.
func (c lifecycleCase) overridesFor(query string) map[string]override {
var overrides map[string]override
for engine, o := range c.engineOverrides {
if answer, ok := o.expect[query]; ok {
if overrides == nil {
overrides = map[string]override{}
}
overrides[engine] = override{want: answer}
}
}
return overrides
}
// docCountOverrides renders the DocCount overrides as a query override.
func (c lifecycleCase) docCountOverrides() map[string]override {
var overrides map[string]override
for engine, o := range c.engineOverrides {
if o.wantDocCount != nil {
if overrides == nil {
overrides = map[string]override{}
}
overrides[engine] = override{want: []string{fmt.Sprint(*o.wantDocCount)}}
}
}
return overrides
}
type expectation struct {
query string
want []string
}
func (c lifecycleCase) label(group string) string {
return fmt.Sprintf("%s-%02d", strings.ToUpper(group), c.id)
}
func (c lifecycleCase) name(group string) string {
return c.label(group) + " " + c.title
}
type lifecycleGroup struct {
name string
fixtures []search.Resource
cases []lifecycleCase
}
func lifecycleGroups() []lifecycleGroup {
return []lifecycleGroup{
deleteLifecycle(),
restoreLifecycle(),
purgeLifecycle(),
purgeSpaceLifecycle(),
moveLifecycle(),
rootScopeLifecycle(),
casePathLifecycle(),
hiddenLifecycle(),
upsertLifecycle(),
idempotencyLifecycle(),
batchLifecycle(),
}
}
// expectAnswer holds an engine to its override when it has one, to the
// expectation otherwise. An override that no longer holds fails on purpose:
// the engine answers as expected now, the override has to go.
func expectAnswer(engine string, answer []string, expected override, overrides map[string]override) {
GinkgoHelper()
if o, ok := overrides[engine]; ok {
Expect(o.rendered()).NotTo(Equal(expected.rendered()), "an override that equals the expectation documents nothing, remove it")
Expect(answer).To(o.matcher(), "the override for %s no longer holds, remove it", engine)
return
}
Expect(answer).To(expected.matcher())
}
// Every case runs once per engine as its own spec, so one engine failing
// leaves the other's answer in the matrix. The groups are Ordered containers:
// their engines are built once in a BeforeAll and shared by the specs inside,
// and they carry on after a failure so every engine gets to answer.
var _ = Describe("Queries", func() {
for groupAt, group := range queryGroups() {
Describe(group.name, Ordered, ContinueOnFailure, func() {
var engines []testEngine
BeforeAll(func() {
engines = newEngines("opencloud-test-engine-parity-"+group.name, group.fixtures)
})
for caseAt, c := range group.cases {
row := matrixRow{
Section: "Queries", Group: group.name, ID: c.label(group.name),
Query: c.query, Scope: matrixScope(c.ref), Limit: c.limit,
Want: c.want, WantCount: c.wantCount, WantBadRequest: c.wantBadRequest, Overrides: renderOverrides(c.engineOverrides),
GroupAt: groupAt, CaseAt: caseAt,
}
planRow(row)
Describe(c.name(group.name), func() {
for _, name := range engineNames {
It("on "+name, func() {
e := engineNamed(engines, name)
if e.unavailable != "" {
recordSkip(row, name)
Skip(e.unavailable)
}
request := &searchService.SearchIndexRequest{Query: c.query, PageSize: c.limit, Ref: c.ref}
expected := override{want: c.want, wantCount: c.wantCount, wantBadRequest: c.wantBadRequest}
_, overridden := c.engineOverrides[name]
if c.wantBadRequest {
_, err := e.backend.Search(context.Background(), request)
answer := badRequestAnswer(err)
recordAnswer(row, name, answer)
if !overridden {
Expect(err).To(BeAssignableToTypeOf(errtypes.BadRequest("")))
}
expectAnswer(name, answer, expected, c.engineOverrides)
return
}
answer, err := ask(e.backend, request)
recordAnswer(row, name, answer)
if !overridden {
Expect(err).NotTo(HaveOccurred(), "the query has to answer, an empty result is an answer")
}
expectAnswer(name, answer, expected, c.engineOverrides)
})
}
})
}
})
}
})
var _ = Describe("Operations", func() {
offset := len(queryGroups())
for groupAt, group := range lifecycleGroups() {
Describe(group.name, func() {
for caseAt, c := range group.cases {
fixtures := c.fixtures
if fixtures == nil {
fixtures = group.fixtures
}
index := fmt.Sprintf("opencloud-test-engine-parity-%s-%d", group.name, c.id)
rows := c.matrixRows(group.name, offset+groupAt, caseAt)
planRow(rows...)
// an operation changes its index, so every spec builds its own engine
Describe(c.name(group.name), func() {
for _, name := range engineNames {
It("on "+name, func() {
e := newEngine(name, index, fixtures)
if e.unavailable != "" {
for _, row := range rows {
recordSkip(row, name)
}
Skip(e.unavailable)
}
// every row gets its answer before anything is asserted, a
// failed assertion must not leave the later rows unanswered
err := c.do(e.backend)
if failed := err != nil; failed != c.wantErr {
for _, row := range rows {
recordAnswer(row, name, badRequestAnswer(err))
}
}
if c.wantErr {
Expect(err).To(HaveOccurred(), "the operation had to report that it did not find the resource")
} else {
Expect(err).NotTo(HaveOccurred(), "the operation under test failed")
}
e.settle()
answers := make([][]string, len(c.expect))
errs := make([]error, len(c.expect))
for i, expect := range c.expect {
answers[i], errs[i] = ask(e.backend, &searchService.SearchIndexRequest{Query: expect.query})
recordAnswer(rows[i], name, answers[i])
}
var count uint64
if c.wantDocCount != nil {
count, err = e.backend.DocCount()
Expect(err).NotTo(HaveOccurred())
recordAnswer(rows[len(rows)-1], name, []string{fmt.Sprint(count)})
}
for i, expect := range c.expect {
overrides := c.overridesFor(expect.query)
if _, overridden := overrides[name]; !overridden {
Expect(errs[i]).NotTo(HaveOccurred(), "the query has to answer, an empty result is an answer")
}
expectAnswer(name, answers[i], override{want: expect.want}, overrides)
}
if c.wantDocCount != nil {
expected := override{want: []string{fmt.Sprint(*c.wantDocCount)}}
expectAnswer(name, []string{fmt.Sprint(count)}, expected, c.docCountOverrides())
}
})
}
})
}
})
}
})
var _ = Describe("Response", func() {
offset := len(queryGroups()) + len(lifecycleGroups())
for groupAt, group := range responseGroups() {
Describe(group.name, Ordered, ContinueOnFailure, func() {
var shared []testEngine
BeforeAll(func() {
shared = newEngines("opencloud-test-engine-parity-"+group.name, group.fixtures)
})
for caseAt, c := range group.cases {
row := matrixRow{
Section: "Response", Group: group.name, ID: c.label(group.name),
Query: c.query, Reads: c.reads, Context: c.after, Want: c.want, Overrides: renderOverrides(c.engineOverrides),
GroupAt: offset + groupAt, CaseAt: caseAt,
}
planRow(row)
index := fmt.Sprintf("opencloud-test-engine-parity-%s-%d", group.name, c.id)
Describe(c.name(group.name), func() {
for _, name := range engineNames {
It("on "+name, func() {
// a case with an operation changes its index, so it gets its own
e := engineNamed(shared, name)
if c.do != nil {
e = newEngine(name, index, group.fixtures)
}
if e.unavailable != "" {
recordSkip(row, name)
Skip(e.unavailable)
}
if c.do != nil {
err := c.do(e.backend)
if err != nil {
recordAnswer(row, name, []string{"error"})
}
Expect(err).NotTo(HaveOccurred(), "the operation under test failed")
e.settle()
}
resp, err := e.backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: c.query})
if err != nil {
recordAnswer(row, name, []string{"error"})
}
Expect(err).NotTo(HaveOccurred(), "the query has to answer, an empty result is an answer")
answer := c.read(resp)
recordAnswer(row, name, answer)
if o, overridden := c.engineOverrides[name]; overridden {
Expect(o.want).NotTo(Equal(c.want), "an override that equals the expectation documents nothing, remove it")
Expect(answer).To(Equal(o.want), "the override for %s no longer holds, remove it", name)
return
}
Expect(answer).To(Equal(c.want), c.reads)
})
}
})
}
})
}
})
@@ -0,0 +1,24 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func booleanGroup() queryGroup {
return queryGroup{
name: "boolean",
fixtures: []search.Resource{
fixtureDoc("alpha.txt", withTags("red")),
fixtureDoc("beta.txt", withTags("blue")),
fixtureDoc("gamma.md", withMime("text/markdown")),
},
cases: []queryCase{
{id: 1, query: `name:"*alpha*" AND name:"*txt*"`, want: []string{"alpha.txt"}},
{id: 2, query: `name:"*alpha*" OR name:"*beta*"`, want: []string{"alpha.txt", "beta.txt"}},
{id: 3, query: `name:"*a*" AND NOT name:"*alpha*"`, want: []string{"beta.txt", "gamma.md"}},
{id: 4, query: `name:"*a*" AND tag:("red")`, want: []string{"alpha.txt"}},
{id: 5, query: `(name:"*alpha*" OR name:"*beta*") AND mediatype:text/plain`, want: []string{"alpha.txt", "beta.txt"}},
{id: 6, query: `name:"*a*" AND mediatype:text/markdown`, want: []string{"gamma.md"}},
},
}
}
@@ -0,0 +1,27 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func contentGroup() queryGroup {
return queryGroup{
name: "content",
fixtures: []search.Resource{
fixtureDoc("monthly.txt", withContent("the monthly reports are due")),
fixtureDoc("links.txt", withContent("see https://opencloud.example.com/help or write to alan@example.org")),
},
cases: []queryCase{
{id: 1, query: `Content:report`, engineOverrides: map[string]override{"bleve": override{want: []string{"monthly.txt"}}}},
{id: 2, query: `Content:REPORTS`, want: []string{"monthly.txt"}},
{id: 3, query: `Content:"monthly reports"`, want: []string{"monthly.txt"}},
{id: 4, query: `Content:"reports monthly"`, engineOverrides: map[string]override{"bleve": override{want: []string{"monthly.txt"}}}},
{id: 5, query: `Content:report*`, want: []string{"monthly.txt"}},
{id: 6, query: `Content:*eport*`, want: []string{"monthly.txt"}},
{id: 7, query: `Content:month*`, want: []string{"monthly.txt"}},
{id: 8, query: `Content:"https://opencloud.example.com/help"`, want: []string{"links.txt"}},
{id: 9, query: `Content:"alan@example.org"`, want: []string{"links.txt"}},
{id: 10, query: `Content:opencloud`, want: []string{"links.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
},
}
}
@@ -0,0 +1,26 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func deletedGroup() queryGroup {
return queryGroup{
name: "deleted",
fixtures: []search.Resource{
fixtureDoc("trashed.txt", isDeleted()),
fixtureDoc("kept.txt"),
fixtureFolder("bin", isDeleted()),
fixtureDoc("receipt.txt", withParent("1$1!bin"), withPath("./bin/receipt.txt"), isDeleted()),
fixtureFolder("shelf"),
fixtureDoc("book.txt", withParent("1$1!shelf"), withPath("./shelf/book.txt")),
},
cases: []queryCase{
{id: 1, query: `name:"*trashed*"`},
{id: 2, query: `name:"*.txt"`, want: []string{"kept.txt", "book.txt"}},
{id: 3, query: `name:"*receipt*"`},
{id: 4, query: `path:"./bin"`},
{id: 5, query: `path:"./shelf"`, want: []string{"shelf", "book.txt"}, engineOverrides: map[string]override{"bleve": override{want: []string{"shelf"}}}},
},
}
}
@@ -0,0 +1,23 @@
package parity
import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func everythingGroup() queryGroup {
return queryGroup{
name: "everything",
fixtures: []search.Resource{
fixtureDoc("alpha.txt"),
fixtureDoc("beta.txt"),
fixtureFolder("box"),
},
cases: []queryCase{
{id: 1, query: `*`, want: []string{"alpha.txt", "beta.txt", "box"}},
{id: 2, query: `name:"*"`, want: []string{"alpha.txt", "beta.txt", "box"}},
{id: 3, query: `*`, limit: 2, wantCount: conversions.ToPointer(2)},
{id: 4, query: `*`, limit: -1, wantCount: conversions.ToPointer(3)},
},
}
}
@@ -0,0 +1,22 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func extensionGroup() queryGroup {
return queryGroup{
name: "extension",
fixtures: []search.Resource{
fixtureDoc("report.txt"),
fixtureDoc("notes.md", withMime("text/markdown")),
fixtureFolder("archive"),
},
cases: []queryCase{
{id: 1, query: `txt`, want: []string{"report.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 2, query: `md`, want: []string{"notes.md"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 3, query: `name:"*.txt"`, want: []string{"report.txt"}},
{id: 4, query: `report`, want: []string{"report.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
},
}
}
@@ -0,0 +1,22 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func favoritesGroup() queryGroup {
return queryGroup{
name: "favorites",
fixtures: []search.Resource{
fixtureDoc("starred.txt", withFavorite("A1B2-Upper")),
fixtureDoc("plain.txt"),
fixtureFolder("keepsakes", withFavorite("A1B2-Upper")),
fixtureDoc("photo.jpg", withParent("1$1!keepsakes"), withPath("./keepsakes/photo.jpg"), withMime("image/jpeg")),
},
cases: []queryCase{
{id: 1, query: `Favorites:"A1B2-Upper"`, want: []string{"starred.txt", "keepsakes"}, engineOverrides: map[string]override{"opensearch": override{}}},
{id: 2, query: `favorite:"A1B2-Upper"`, want: []string{"starred.txt", "keepsakes"}, engineOverrides: map[string]override{"opensearch": override{}}},
{id: 3, query: `Favorites:"somebody-else"`},
},
}
}
@@ -0,0 +1,41 @@
package parity
import (
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func fieldsGroup() queryGroup {
return queryGroup{
name: "fields",
fixtures: []search.Resource{
fixtureDoc("small.txt", withSize(42)),
fixtureDoc("old.txt", withMtime("2020-01-01T00:00:00Z")),
fixtureDoc("known.txt", withID("1$1!23")),
fixtureDoc("cased.txt", withID("1$1!AB-23")),
fixtureDoc("hidden.txt", isHidden()),
fixtureDoc("plain.txt"),
fixtureFolder("box"),
fixtureDoc("boxed.txt", withParent("1$1!box"), withPath("./box/boxed.txt")),
fixtureDoc("song.mp3", withMime("audio/mpeg"), withAudio(&libregraph.Audio{Artist: libregraph.PtrString("Some Artist")})),
},
cases: []queryCase{
{id: 1, query: `size:42`, want: []string{"small.txt"}},
{id: 2, query: `mtime<"2021-01-01T00:00:00Z"`, want: []string{"old.txt"}},
{id: 3, query: `id:"1$1!23"`, want: []string{"known.txt"}},
{id: 4, query: `hidden:true`, want: []string{"hidden.txt"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 5, query: `type:file`, want: []string{"small.txt", "old.txt", "known.txt", "cased.txt", "hidden.txt", "plain.txt", "boxed.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{want: []string{"error"}}}},
{id: 6, query: `type:folder`, want: []string{"box"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{want: []string{"error"}}}},
{id: 7, query: `unknown:field`},
{id: 8, query: `type:File`, want: []string{"small.txt", "old.txt", "known.txt", "cased.txt", "hidden.txt", "plain.txt", "boxed.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{want: []string{"error"}}}},
{id: 9, query: `type:FOLDER`, want: []string{"box"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{want: []string{"error"}}}},
{id: 10, query: `hidden:TRUE`, want: []string{"hidden.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{want: []string{"error"}}}},
{id: 11, query: `id:"1$1!AB-23"`, want: []string{"cased.txt"}, engineOverrides: map[string]override{"opensearch": override{}}},
{id: 12, query: `id:"1$1!ab-23"`},
// a facet value keeps its case, the field is not marked lowercase
{id: 13, query: `audio.artist:"Some Artist"`, want: []string{"song.mp3"}, engineOverrides: map[string]override{"opensearch": override{}}},
{id: 14, query: `audio.artist:"some artist"`},
},
}
}
@@ -0,0 +1,18 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func invalidGroup() queryGroup {
return queryGroup{
name: "invalid",
fixtures: []search.Resource{
fixtureDoc("alpha.txt"),
},
cases: []queryCase{
{id: 1, query: `AND mediatype:document`, wantBadRequest: true},
{id: 2, query: `mediatype:document AND`, want: []string{"alpha.txt"}},
},
}
}
@@ -0,0 +1,25 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func mediatypeGroup() queryGroup {
return queryGroup{
name: "mediatype",
fixtures: []search.Resource{
fixtureDoc("notes.md", withMime("text/markdown")),
fixtureDoc("photo.jpg", withMime("image/jpeg")),
fixtureFolder("albums"),
fixtureFolder("drafts"),
},
cases: []queryCase{
{id: 1, query: `mediatype:text/markdown`, want: []string{"notes.md"}},
{id: 2, query: `mediatype:TEXT/MARKDOWN`, want: []string{"notes.md"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 3, query: `mediatype:image/jpeg`, want: []string{"photo.jpg"}},
{id: 4, query: `mediatype:*jpeg`, want: []string{"photo.jpg"}},
{id: 5, query: `mediatype:image`, want: []string{"photo.jpg"}},
{id: 6, query: `mediatype:folder`, want: []string{"albums", "drafts"}},
},
}
}
@@ -0,0 +1,66 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func nameGroup() queryGroup {
return queryGroup{
name: "name",
fixtures: []search.Resource{
fixtureFolder("new-folder"),
fixtureDoc("quarterly notes.txt"),
fixtureDoc("Report.txt"),
fixtureDoc("Übung.txt"),
fixtureDoc("a+b.txt"),
fixtureDoc("c(d).txt"),
fixtureDoc("e&f.txt"),
fixtureDoc("v1.2.3.txt"),
fixtureDoc("foo bar.txt"),
fixtureDoc(fixtureLongName),
},
cases: []queryCase{
{id: 1, query: `new`, want: []string{"new-folder"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 2, query: `quarterly`, want: []string{"quarterly notes.txt"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 3, query: `report`, want: []string{"Report.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 4, query: `name:"*new-folder*"`, want: []string{"new-folder"}},
{id: 5, query: `name:"*w-fol*"`, want: []string{"new-folder"}},
{id: 6, query: `name:"*oo ba*"`, want: []string{"foo bar.txt"}},
{id: 7, query: `name:"*REPORT*"`, want: []string{"Report.txt"}},
{id: 8, query: `name:"*übung*"`, want: []string{"Übung.txt"}, engineOverrides: map[string]override{"opensearch": override{}}},
{id: 9, query: `name:"*ÜBUNG*"`, want: []string{"Übung.txt"}, engineOverrides: map[string]override{"opensearch": override{}}},
{id: 10, query: `name:"*a+b*"`, want: []string{"a+b.txt"}},
{id: 11, query: `name:"*c(d)*"`, want: []string{"c(d).txt"}},
{id: 12, query: `name:"*e&f*"`, want: []string{"e&f.txt"}},
{id: 13, query: `name:"*v1.2*"`, want: []string{"v1.2.3.txt"}},
{id: 14, query: `new-folder`, want: []string{"new-folder"}},
{id: 15, query: `*folder*`, want: []string{"new-folder"}},
{id: 16, query: `name:"*foo bar*"`, want: []string{"foo bar.txt"}},
{id: 17, query: `name:"foo bar.txt"`, want: []string{"foo bar.txt"}},
{id: 18, query: `name:"*needle*"`, want: []string{fixtureLongName}, engineOverrides: map[string]override{"opensearch": override{}}},
{id: 19, query: `name:"report*"`, want: []string{"Report.txt"}},
{id: 20, query: `name:"*report"`, want: []string{"Report.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 21, query: `name:"Rep*rt.txt"`, want: []string{"Report.txt"}},
{id: 22, query: `Name:"*report*"`, want: []string{"Report.txt"}},
{id: 23, query: `NAME:"*report*"`, want: []string{"Report.txt"}, engineOverrides: map[string]override{"opensearch": override{}}},
{id: 24, query: `name:Rep?rt.txt`, want: []string{"Report.txt"}, engineOverrides: map[string]override{"opensearch": override{}}},
{id: 25, query: `name:"*eport"`, want: []string{"Report.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 26, query: `name:"repor*"`, want: []string{"Report.txt"}},
{id: 27, query: `REPORT`, want: []string{"Report.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 28, query: `name:REPORT`, want: []string{"Report.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 29, query: `name:"REPORT.TXT"`, want: []string{"Report.txt"}},
{id: 30, query: `name:"FOO BAR.TXT"`, want: []string{"foo bar.txt"}},
{id: 31, query: `name:"ÜBUNG.TXT"`, want: []string{"Übung.txt"}},
{id: 32, query: `name:"folder*"`},
{id: 33, query: `name:"*new"`},
{id: 34, query: `name:new`, want: []string{"new-folder"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 35, query: `name:"new"`, engineOverrides: map[string]override{"opensearch": override{want: []string{"new-folder"}}}},
{id: 36, query: `name:"new-folder"`, want: []string{"new-folder"}},
{id: 37, query: `name:"new-*"`, want: []string{"new-folder"}},
{id: 38, query: `name:"new*"`, want: []string{"new-folder"}},
{id: 39, query: `name:new-*`, want: []string{"new-folder"}},
{id: 40, query: `name:"*-folder"`, want: []string{"new-folder"}},
{id: 41, query: `name:"Rep?rt.txt"`, want: []string{"Report.txt"}, engineOverrides: map[string]override{"opensearch": override{}}},
},
}
}
@@ -0,0 +1,28 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func pathGroup() queryGroup {
return queryGroup{
name: "path",
fixtures: []search.Resource{
fixtureFolder("parent"),
fixtureDoc("child.jpg", withMime("image/jpeg"), withPath("./parent/child.jpg")),
fixtureFolder("docs-lower", withPath("./documents")),
fixtureFolder("docs-upper", withPath("./DOCUMENTS")),
fixtureFolder("docs-mixed", withPath("./Documents")),
},
cases: []queryCase{
{id: 1, query: `path:"./parent"`, want: []string{"parent", "child.jpg"}, engineOverrides: map[string]override{"bleve": override{want: []string{"parent"}}}},
{id: 2, query: `path:"./parent/child.jpg"`, want: []string{"child.jpg"}},
{id: 3, query: `path:"./Parent"`, engineOverrides: map[string]override{"opensearch": override{want: []string{"child.jpg", "parent"}}}},
{id: 4, query: `path:"*child*"`, want: []string{"child.jpg"}},
{id: 5, query: `path:"./documents"`, want: []string{"docs-lower"}, engineOverrides: map[string]override{"opensearch": override{want: []string{"docs-lower", "docs-mixed", "docs-upper"}}}},
{id: 6, query: `path:"./DOCUMENTS"`, want: []string{"docs-upper"}, engineOverrides: map[string]override{"opensearch": override{want: []string{"docs-lower", "docs-mixed", "docs-upper"}}}},
{id: 7, query: `path:"./Documents"`, want: []string{"docs-mixed"}, engineOverrides: map[string]override{"opensearch": override{want: []string{"docs-lower", "docs-mixed", "docs-upper"}}}},
{id: 8, query: `path:"./parent/"`, want: []string{"parent", "child.jpg"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
},
}
}
@@ -0,0 +1,26 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func rangeGroup() queryGroup {
return queryGroup{
name: "range",
fixtures: []search.Resource{
fixtureDoc("small.txt", withSize(50)),
fixtureDoc("big.txt", withSize(500)),
fixtureDoc("ancient.txt", withSize(10), withMtime("2020-01-01T00:00:00Z")),
},
cases: []queryCase{
{id: 1, query: `size>100`, want: []string{"big.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 2, query: `size<100`, want: []string{"small.txt", "ancient.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 3, query: `mtime>"2021-01-01T00:00:00Z"`, want: []string{"small.txt", "big.txt"}},
{id: 4, query: `mtime<"2021-01-01T00:00:00Z"`, want: []string{"ancient.txt"}},
{id: 5, query: `Mtime:"today"`, want: []string{"small.txt", "big.txt"}},
{id: 6, query: `Mtime:"yesterday"`},
{id: 7, query: `mtime>2021`},
{id: 8, query: `name>100`},
},
}
}
@@ -0,0 +1,34 @@
package parity
import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func samenameGroup() queryGroup {
folder := fixtureFolder("doc", withID("1$1!2"))
under := func(path string) *searchMessage.Reference {
return &searchMessage.Reference{
ResourceId: &searchMessage.ResourceID{StorageId: "1", SpaceId: "1", OpaqueId: "1"},
Path: path,
}
}
return queryGroup{
name: "samename",
fixtures: []search.Resource{
folder,
fixtureDoc("doc.pdf", withID("1$1!3")),
fixtureDoc("file.pdf", withID("1$1!4")),
fixtureDoc("doc.pdf", withID("1$1!5"), withParent(folder.ID), withPath("./doc/doc.pdf")),
fixtureDoc("file.pdf", withID("1$1!6"), withParent(folder.ID), withPath("./doc/file.pdf")),
},
cases: []queryCase{
{id: 1, query: `name:"*doc*"`, wantCount: conversions.ToPointer(3)},
{id: 2, query: `name:"*doc*"`, ref: under("./doc"), wantCount: conversions.ToPointer(2)},
{id: 3, query: `name:"*file*"`, wantCount: conversions.ToPointer(2)},
{id: 4, query: `name:"*file*"`, ref: under("./doc"), wantCount: conversions.ToPointer(1)},
},
}
}
@@ -0,0 +1,32 @@
package parity
import (
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func scopeGroup() queryGroup {
space := func(path string) *searchMessage.Reference {
return &searchMessage.Reference{
ResourceId: &searchMessage.ResourceID{StorageId: "1", SpaceId: "1", OpaqueId: "1"},
Path: path,
}
}
return queryGroup{
name: "scope",
fixtures: []search.Resource{
fixtureFolder("parent"),
fixtureDoc("child.pdf", withPath("./parent/child.pdf")),
fixtureDoc("outside.txt"),
fixtureDoc("elsewhere.txt", withRoot("2$2!1")),
},
cases: []queryCase{
{id: 1, query: `*`, want: []string{"parent", "child.pdf", "outside.txt", "elsewhere.txt"}},
{id: 2, query: `*`, ref: space(""), want: []string{"parent", "child.pdf", "outside.txt"}},
{id: 3, query: `*`, ref: space("./parent"), want: []string{"parent", "child.pdf"}},
{id: 4, query: `*`, ref: space("./parent/child.pdf"), want: []string{"child.pdf"}},
{id: 5, query: `name:"*elsewhere*"`, ref: space(""), want: nil},
},
}
}
@@ -0,0 +1,38 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func stressGroup() queryGroup {
return queryGroup{
name: "stress",
fixtures: []search.Resource{
fixtureDoc("quarterly report.docx",
withMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
withTags("final"), withSize(2000)),
fixtureDoc("draft report.txt", withTags("draft"), withSize(50), withMtime("2020-01-01T00:00:00Z")),
fixtureDoc("photo.jpg", withMime("image/jpeg"), withTags("final")),
fixtureDoc("notes.md", withMime("text/markdown"), isHidden()),
fixtureFolder("archive"),
},
cases: []queryCase{
{id: 1, query: `name:"*report*" AND mediatype:document`, want: []string{"quarterly report.docx", "draft report.txt"}},
{id: 2, query: `name:"*report*" AND NOT tag:("draft")`, want: []string{"quarterly report.docx"}, engineOverrides: map[string]override{"bleve": override{want: []string{"draft report.txt", "quarterly report.docx"}}}},
{id: 3, query: `(tag:("final") OR tag:("draft")) AND mediatype:image`, want: []string{"photo.jpg"}},
{id: 4, query: `mediatype:document AND mtime>"2021-01-01T00:00:00Z"`, want: []string{"quarterly report.docx", "notes.md"}},
{id: 5, query: `name:"*report*" AND size>100`, want: []string{"quarterly report.docx"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 6, query: `tag:("final") AND NOT mediatype:folder`, want: []string{"quarterly report.docx", "photo.jpg"}},
{id: 7, query: `hidden:true AND name:"*notes*"`, want: []string{"notes.md"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 8, query: `name:quarterly report`, want: []string{"quarterly report.docx"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 9, query: `name:"quarterly report"`},
{id: 10, query: `name:"quarterly report.docx"`, want: []string{"quarterly report.docx"}},
{id: 11, query: `NOT tag:("draft")`, want: []string{"quarterly report.docx", "photo.jpg", "notes.md", "archive"}, engineOverrides: map[string]override{"bleve": override{want: []string{"archive", "draft report.txt", "notes.md", "photo.jpg", "quarterly report.docx"}}}},
{id: 12, query: `tag:("final") OR hidden:true`, want: []string{"quarterly report.docx", "photo.jpg", "notes.md"}, engineOverrides: map[string]override{"bleve": override{want: []string{"photo.jpg", "quarterly report.docx"}}}},
{id: 13, query: `(name:"*report*" OR name:"*notes*") AND NOT (tag:("draft") OR hidden:true)`, want: []string{"quarterly report.docx"}, engineOverrides: map[string]override{"bleve": override{want: []string{"notes.md", "quarterly report.docx"}}}},
{id: 14, query: `mediatype:image OR (mediatype:document AND tag:("draft"))`, want: []string{"photo.jpg", "draft report.txt"}},
{id: 15, query: `NOT (mediatype:folder OR hidden:true)`, want: []string{"quarterly report.docx", "draft report.txt", "photo.jpg"}, engineOverrides: map[string]override{"bleve": override{want: []string{"draft report.txt", "notes.md", "photo.jpg", "quarterly report.docx"}}}},
{id: 16, query: `name:"*report*" AND (size>100 OR tag:("draft"))`, want: []string{"quarterly report.docx", "draft report.txt"}, engineOverrides: map[string]override{"bleve": override{want: []string{"draft report.txt"}}, "opensearch": override{want: []string{"draft report.txt"}}}},
},
}
}
@@ -0,0 +1,34 @@
package parity
import (
"strings"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func tagsGroup() queryGroup {
longTag := strings.Repeat("z", 300) + "needle"
return queryGroup{
name: "tags",
fixtures: []search.Resource{
fixtureDoc("invoice.txt", withTags("foo-bar")),
fixtureDoc("memo.txt", withTags("foo")),
fixtureDoc("spaced.txt", withTags("spaced tag")),
fixtureFolder("project", withTags("work")),
fixtureDoc("draft.txt", withParent("1$1!project"), withPath("./project/draft.txt")),
fixtureDoc("longtag.txt", withTags(longTag)),
},
cases: []queryCase{
{id: 1, query: `name:"*foo-bar*"`},
{id: 2, query: `tag:("foo-bar")`, want: []string{"invoice.txt"}},
{id: 3, query: `tag:("foo")`, want: []string{"memo.txt"}},
{id: 4, query: `tag:("FOO-BAR")`, want: []string{"invoice.txt"}},
{id: 5, query: `tag:("*foo*")`, want: []string{"invoice.txt", "memo.txt"}},
{id: 6, query: `tag:("spaced tag")`, want: []string{"spaced.txt"}},
{id: 7, query: `tag:("*paced ta*")`, want: []string{"spaced.txt"}},
{id: 8, query: `tag:("work")`, want: []string{"project"}},
{id: 9, query: `tag:("` + longTag + `")`, want: []string{"longtag.txt"}, engineOverrides: map[string]override{"opensearch": override{}}},
},
}
}
@@ -0,0 +1,23 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func titleGroup() queryGroup {
return queryGroup{
name: "title",
fixtures: []search.Resource{
fixtureDoc("q1.html", withMime("text/html"), withTitle("quarterly report")),
},
cases: []queryCase{
{id: 1, query: `Title:"quarterly report"`, want: []string{"q1.html"}},
{id: 2, query: `Title:quarterly`, want: []string{"q1.html"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 3, query: `Title:QUARTERLY`, want: []string{"q1.html"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 4, query: `Title:quarterl*`, want: []string{"q1.html"}},
{id: 5, query: `Title:"*ly rep*"`, want: []string{"q1.html"}},
{id: 6, query: `title:quarterly`, want: []string{"q1.html"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{}}},
{id: 7, query: `Title:"QUARTERLY REPORT"`, want: []string{"q1.html"}, engineOverrides: map[string]override{"bleve": override{}}},
},
}
}
@@ -0,0 +1,26 @@
package parity
import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func visibilityGroup() queryGroup {
return queryGroup{
name: "visibility",
fixtures: []search.Resource{
fixtureDoc("visible.txt"),
fixtureDoc("dotfile.txt", isHidden()),
fixtureFolder(".private", isHidden()),
fixtureDoc("secret.txt", withParent("1$1!.private"), withPath("./.private/secret.txt"), isHidden()),
},
cases: []queryCase{
{id: 1, query: `hidden:true`, want: []string{"dotfile.txt", ".private", "secret.txt"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 2, query: `hidden:TRUE`, want: []string{"dotfile.txt", ".private", "secret.txt"}, engineOverrides: map[string]override{"bleve": override{}, "opensearch": override{want: []string{"error"}}}},
{id: 3, query: `hidden:false`, want: []string{"visible.txt"}, engineOverrides: map[string]override{"bleve": override{}}},
{id: 4, query: `name:"*secret*"`, want: []string{"secret.txt"}},
{id: 5, query: `path:"./.private"`, want: []string{".private", "secret.txt"}, engineOverrides: map[string]override{"bleve": override{want: []string{".private"}}}},
{id: 6, query: `hidden:banana`, engineOverrides: map[string]override{"opensearch": override{want: []string{"error"}}}},
{id: 7, query: `hidden:"true"`, want: []string{"dotfile.txt", ".private", "secret.txt"}, engineOverrides: map[string]override{"bleve": override{}}},
},
}
}
@@ -0,0 +1,102 @@
package parity
import (
"fmt"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func entityGroup() responseGroup {
parent := fixtureFolder("parent", withID("1$1!2"))
doc := fixtureDoc("bar.pdf",
withID("1$1!3"),
withParent(parent.ID),
withPath("./parent/bar.pdf"),
withMime("application/pdf"),
withSize(1234),
)
notes := fixtureDoc("notes.txt", withID("1$1!4"), withContent("foo bar baz"))
moved := func(e search.Engine) error { return e.Move(parent.ID, "1$1!9", "./somewhere/newname") }
return responseGroup{
name: "entity",
fixtures: []search.Resource{parent, doc, notes},
cases: []responseCase{
{
id: 1, query: `name:"bar.pdf"`, reads: "Ref.Path", want: []string{"./parent/bar.pdf"},
read: reads(func(m *searchMessage.Match) string { return m.GetEntity().GetRef().GetPath() }),
},
{
id: 2, query: `name:"bar.pdf"`, reads: "Name", want: []string{"bar.pdf"},
read: reads(func(m *searchMessage.Match) string { return m.GetEntity().GetName() }),
},
{
id: 3, query: `name:"bar.pdf"`, reads: "Id", want: []string{"1$1!3"},
read: reads(func(m *searchMessage.Match) string { return resourceID(m.GetEntity().GetId()) }),
},
{
id: 4, query: `name:"bar.pdf"`, reads: "ParentId", want: []string{"1$1!2"},
read: reads(func(m *searchMessage.Match) string { return resourceID(m.GetEntity().GetParentId()) }),
},
{
id: 5, query: `name:"bar.pdf"`, reads: "Ref.ResourceId", want: []string{"1$1!1"},
read: reads(func(m *searchMessage.Match) string { return resourceID(m.GetEntity().GetRef().GetResourceId()) }),
},
{
id: 6, query: `name:"bar.pdf"`, reads: "Size", want: []string{"1234"},
read: reads(func(m *searchMessage.Match) string { return fmt.Sprint(m.GetEntity().GetSize()) }),
},
{
id: 7, query: `name:"bar.pdf"`, reads: "Type", want: []string{"1"},
read: reads(func(m *searchMessage.Match) string { return fmt.Sprint(m.GetEntity().GetType()) }),
},
{
id: 8, query: `name:"bar.pdf"`, reads: "MimeType", want: []string{"application/pdf"},
read: reads(func(m *searchMessage.Match) string { return m.GetEntity().GetMimeType() }),
},
{
id: 9, query: `name:"bar.pdf"`, reads: "Deleted", want: []string{"false"},
read: reads(func(m *searchMessage.Match) string { return fmt.Sprint(m.GetEntity().GetDeleted()) }),
},
{
id: 10, query: `name:"bar.pdf"`, reads: "Score", want: []string{"above zero"},
read: reads(func(m *searchMessage.Match) string {
if m.GetScore() > 0 {
return "above zero"
}
return fmt.Sprint(m.GetScore())
}),
},
{
id: 11, query: `path:"./parent"`, reads: "TotalMatches", want: []string{"2"}, engineOverrides: map[string]override{"bleve": override{want: []string{"1"}}},
read: func(resp *searchService.SearchIndexResponse) []string {
return []string{fmt.Sprint(resp.GetTotalMatches())}
},
},
{
id: 12, query: `name:"*notes*"`, reads: "Highlights", want: []string{`""`},
read: reads(func(m *searchMessage.Match) string { return fmt.Sprintf("%q", m.GetEntity().GetHighlights()) }),
},
{
id: 13, query: `content:bar`, reads: "Highlights", want: []string{"foo <mark>bar</mark> baz"},
read: reads(func(m *searchMessage.Match) string { return m.GetEntity().GetHighlights() }),
},
{
id: 14, do: moved, after: "moved to another parent", query: `name:"newname"`, reads: "ParentId", want: []string{"1$1!9"},
read: reads(func(m *searchMessage.Match) string { return resourceID(m.GetEntity().GetParentId()) }),
},
{
id: 15, do: moved, after: "moved to another parent", query: `name:"bar.pdf"`, reads: "ParentId", want: []string{"1$1!2"},
read: reads(func(m *searchMessage.Match) string { return resourceID(m.GetEntity().GetParentId()) }),
},
{
id: 16, do: moved, after: "moved to another parent", query: `name:"bar.pdf"`, reads: "Ref.Path", want: []string{"./somewhere/newname/bar.pdf"},
read: reads(func(m *searchMessage.Match) string { return m.GetEntity().GetRef().GetPath() }),
},
},
}
}
@@ -0,0 +1,143 @@
package parity
import (
"fmt"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func metadataGroup() responseGroup {
song := fixtureDoc("some_song.mp3",
withID("1$1!5"),
withMime("audio/mpeg"),
withAudio(&libregraph.Audio{
Album: libregraph.PtrString("Some Album"),
AlbumArtist: libregraph.PtrString("Some AlbumArtist"),
Artist: libregraph.PtrString("Some Artist"),
Bitrate: libregraph.PtrInt64(192),
Composers: libregraph.PtrString("Some Composers"),
Copyright: libregraph.PtrString(""),
Disc: libregraph.PtrInt32(2),
DiscCount: libregraph.PtrInt32(5),
Duration: libregraph.PtrInt64(225000),
Genre: libregraph.PtrString("Some Genre"),
HasDrm: libregraph.PtrBool(false),
IsVariableBitrate: libregraph.PtrBool(true),
Title: libregraph.PtrString("Some Title"),
Track: libregraph.PtrInt32(34),
TrackCount: libregraph.PtrInt32(99),
Year: libregraph.PtrInt32(2004),
}),
)
team := fixtureDoc("team.jpg",
withID("1$1!6"),
withMime("image/jpeg"),
withLocation(&libregraph.GeoCoordinates{
Altitude: libregraph.PtrFloat64(1047.7),
Latitude: libregraph.PtrFloat64(49.48675890884328),
Longitude: libregraph.PtrFloat64(11.103870357204285),
}),
)
indexed := []string{
"Album=Some Album",
"AlbumArtist=Some AlbumArtist",
"Artist=Some Artist",
"Bitrate=192",
"Composers=Some Composers",
"Copyright=",
"Disc=2",
"DiscCount=5",
"Duration=225000",
"Genre=Some Genre",
"HasDrm=false",
"IsVariableBitrate=true",
"Title=Some Title",
"Track=34",
"TrackCount=99",
"Year=2004",
}
located := []string{
"Altitude=1047.7",
"Latitude=49.48675890884328",
"Longitude=11.103870357204285",
}
return responseGroup{
name: "metadata",
fixtures: []search.Resource{song, team},
cases: []responseCase{
{
id: 1, query: `*song*`, reads: "Audio", want: unchanged(indexed),
read: readsMany(func(m *searchMessage.Match) []string {
audio := m.GetEntity().GetAudio()
return changes(indexed, []string{
fmt.Sprintf("Album=%s", audio.GetAlbum()),
fmt.Sprintf("AlbumArtist=%s", audio.GetAlbumArtist()),
fmt.Sprintf("Artist=%s", audio.GetArtist()),
fmt.Sprintf("Bitrate=%d", audio.GetBitrate()),
fmt.Sprintf("Composers=%s", audio.GetComposers()),
fmt.Sprintf("Copyright=%s", audio.GetCopyright()),
fmt.Sprintf("Disc=%d", audio.GetDisc()),
fmt.Sprintf("DiscCount=%d", audio.GetDiscCount()),
fmt.Sprintf("Duration=%d", audio.GetDuration()),
fmt.Sprintf("Genre=%s", audio.GetGenre()),
fmt.Sprintf("HasDrm=%t", audio.GetHasDrm()),
fmt.Sprintf("IsVariableBitrate=%t", audio.GetIsVariableBitrate()),
fmt.Sprintf("Title=%s", audio.GetTitle()),
fmt.Sprintf("Track=%d", audio.GetTrack()),
fmt.Sprintf("TrackCount=%d", audio.GetTrackCount()),
fmt.Sprintf("Year=%d", audio.GetYear()),
})
}),
},
{
id: 2, query: `*team*`, reads: "Location", want: unchanged(located),
read: readsMany(func(m *searchMessage.Match) []string {
location := m.GetEntity().GetLocation()
return changes(located, []string{
fmt.Sprintf("Altitude=%v", location.GetAltitude()),
fmt.Sprintf("Latitude=%v", location.GetLatitude()),
fmt.Sprintf("Longitude=%v", location.GetLongitude()),
})
}),
},
{
id: 3, query: `*team*`, reads: "Audio", want: []string{"none"},
read: reads(func(m *searchMessage.Match) string {
if m.GetEntity().GetAudio() == nil {
return "none"
}
return "set"
}),
},
},
}
}
func unchanged(indexed []string) []string {
return []string{fmt.Sprintf("all %d fields unchanged", len(indexed))}
}
func changes(indexed, got []string) []string {
var off []string
for i, field := range indexed {
if got[i] != field {
off = append(off, fmt.Sprintf("%s instead of %s", got[i], field))
}
}
if len(off) == 0 {
return unchanged(indexed)
}
return off
}
@@ -501,3 +501,19 @@ Feature: Search
| old |
| new |
| spaces |
Scenario: a deleted space leaves the search index
Given using spaces DAV path
And the administrator has assigned the role "Space Admin" to user "Alice" using the Graph API
And user "Alice" has created a space "index-space" with the default quota using the Graph API
And user "Alice" has uploaded a file inside space "index-space" with content "some data" to "inSpace.txt"
When user "Alice" searches for "*inSpace*" using the WebDAV API
Then the HTTP status code should be "207"
And the search result of user "Alice" should contain these entries:
| inSpace.txt |
When user "Alice" has disabled a space "index-space"
And user "Alice" has deleted a space "index-space"
And user "Alice" searches for "*inSpace*" using the WebDAV API
Then the HTTP status code should be "207"
And the search result should contain "0" entries