// Copyright 2012 Google Inc. All rights reserved. // Use of this source code is governed by the Apache 2.0 // license that can be found in the LICENSE file. package search import ( "errors" "fmt" "reflect" "strings" "testing" "time" "github.com/golang/protobuf/proto" "google.golang.org/appengine" "google.golang.org/appengine/internal/aetesting" pb "google.golang.org/appengine/internal/search" ) type TestDoc struct { String string Atom Atom HTML HTML Float float64 Location appengine.GeoPoint Time time.Time } type FieldListWithMeta struct { Fields FieldList Meta *DocumentMetadata } func (f *FieldListWithMeta) Load(fields []Field, meta *DocumentMetadata) error { f.Meta = meta return f.Fields.Load(fields, nil) } func (f *FieldListWithMeta) Save() ([]Field, *DocumentMetadata, error) { fields, _, err := f.Fields.Save() return fields, f.Meta, err } // Assert that FieldListWithMeta satisfies FieldLoadSaver var _ FieldLoadSaver = &FieldListWithMeta{} var ( float = 3.14159 floatOut = "3.14159e+00" latitude = 37.3894 longitude = 122.0819 testGeo = appengine.GeoPoint{latitude, longitude} testString = "foobar" testTime = time.Unix(1337324400, 0) testTimeOut = "1337324400000" searchMeta = &DocumentMetadata{ Rank: 42, } searchDoc = TestDoc{ String: testString, Atom: Atom(testString), HTML: HTML(testString), Float: float, Location: testGeo, Time: testTime, } searchFields = FieldList{ Field{Name: "String", Value: testString}, Field{Name: "Atom", Value: Atom(testString)}, Field{Name: "HTML", Value: HTML(testString)}, Field{Name: "Float", Value: float}, Field{Name: "Location", Value: testGeo}, Field{Name: "Time", Value: testTime}, } // searchFieldsWithLang is a copy of the searchFields with the Language field // set on text/HTML Fields. searchFieldsWithLang = FieldList{} protoFields = []*pb.Field{ newStringValueField("String", testString, pb.FieldValue_TEXT), newStringValueField("Atom", testString, pb.FieldValue_ATOM), newStringValueField("HTML", testString, pb.FieldValue_HTML), newStringValueField("Float", floatOut, pb.FieldValue_NUMBER), { Name: proto.String("Location"), Value: &pb.FieldValue{ Geo: &pb.FieldValue_Geo{ Lat: proto.Float64(latitude), Lng: proto.Float64(longitude), }, Type: pb.FieldValue_GEO.Enum(), }, }, newStringValueField("Time", testTimeOut, pb.FieldValue_DATE), } ) func init() { for _, f := range searchFields { if f.Name == "String" || f.Name == "HTML" { f.Language = "en" } searchFieldsWithLang = append(searchFieldsWithLang, f) } } func newStringValueField(name, value string, valueType pb.FieldValue_ContentType) *pb.Field { return &pb.Field{ Name: proto.String(name), Value: &pb.FieldValue{ StringValue: proto.String(value), Type: valueType.Enum(), }, } } func newFacet(name, value string, valueType pb.FacetValue_ContentType) *pb.Facet { return &pb.Facet{ Name: proto.String(name), Value: &pb.FacetValue{ StringValue: proto.String(value), Type: valueType.Enum(), }, } } func TestValidIndexNameOrDocID(t *testing.T) { testCases := []struct { s string want bool }{ {"", true}, {"!", false}, {"$", true}, {"!bad", false}, {"good!", true}, {"alsoGood", true}, {"has spaces", false}, {"is_inva\xffid_UTF-8", false}, {"is_non-ASCïI", false}, {"underscores_are_ok", true}, } for _, tc := range testCases { if got := validIndexNameOrDocID(tc.s); got != tc.want { t.Errorf("%q: got %v, want %v", tc.s, got, tc.want) } } } func TestLoadDoc(t *testing.T) { got, want := TestDoc{}, searchDoc if err := loadDoc(&got, &pb.Document{Field: protoFields}, nil); err != nil { t.Fatalf("loadDoc: %v", err) } if got != want { t.Errorf("loadDoc: got %v, wanted %v", got, want) } } func TestSaveDoc(t *testing.T) { got, err := saveDoc(&searchDoc) if err != nil { t.Fatalf("saveDoc: %v", err) } want := protoFields if !reflect.DeepEqual(got.Field, want) { t.Errorf("\ngot %v\nwant %v", got, want) } } func TestSaveDocUsesDefaultedRankIfNotSpecified(t *testing.T) { got, err := saveDoc(&searchDoc) if err != nil { t.Fatalf("saveDoc: %v", err) } orderIdSource := got.GetOrderIdSource() if orderIdSource != pb.Document_DEFAULTED { t.Errorf("OrderIdSource: got %v, wanted DEFAULTED", orderIdSource) } } func TestLoadFieldList(t *testing.T) { var got FieldList want := searchFieldsWithLang if err := loadDoc(&got, &pb.Document{Field: protoFields}, nil); err != nil { t.Fatalf("loadDoc: %v", err) } if !reflect.DeepEqual(got, want) { t.Errorf("\ngot %v\nwant %v", got, want) } } func TestLangFields(t *testing.T) { fl := &FieldList{ {Name: "Foo", Value: "I am English", Language: "en"}, {Name: "Bar", Value: "私は日本人だ", Language: "jp"}, } var got FieldList doc, err := saveDoc(fl) if err != nil { t.Fatalf("saveDoc: %v", err) } if err := loadDoc(&got, doc, nil); err != nil { t.Fatalf("loadDoc: %v", err) } if want := fl; !reflect.DeepEqual(&got, want) { t.Errorf("got %v\nwant %v", got, want) } } func TestSaveFieldList(t *testing.T) { got, err := saveDoc(&searchFields) if err != nil { t.Fatalf("saveDoc: %v", err) } want := protoFields if !reflect.DeepEqual(got.Field, want) { t.Errorf("\ngot %v\nwant %v", got, want) } } func TestLoadFieldAndExprList(t *testing.T) { var got, want FieldList for i, f := range searchFieldsWithLang { f.Derived = (i >= 2) // First 2 elements are "fields", next are "expressions". want = append(want, f) } doc, expr := &pb.Document{Field: protoFields[:2]}, protoFields[2:] if err := loadDoc(&got, doc, expr); err != nil { t.Fatalf("loadDoc: %v", err) } if !reflect.DeepEqual(got, want) { t.Errorf("got %v\nwant %v", got, want) } } func TestLoadMeta(t *testing.T) { var got FieldListWithMeta want := FieldListWithMeta{ Meta: searchMeta, Fields: searchFieldsWithLang, } doc := &pb.Document{ Field: protoFields, OrderId: proto.Int32(42), OrderIdSource: pb.Document_SUPPLIED.Enum(), } if err := loadDoc(&got, doc, nil); err != nil { t.Fatalf("loadDoc: %v", err) } if !reflect.DeepEqual(got, want) { t.Errorf("\ngot %v\nwant %v", got, want) } } func TestSaveMeta(t *testing.T) { got, err := saveDoc(&FieldListWithMeta{ Meta: searchMeta, Fields: searchFields, }) if err != nil { t.Fatalf("saveDoc: %v", err) } want := &pb.Document{ Field: protoFields, OrderId: proto.Int32(42), OrderIdSource: pb.Document_SUPPLIED.Enum(), } if !proto.Equal(got, want) { t.Errorf("\ngot %v\nwant %v", got, want) } } func TestSaveMetaWithDefaultedRank(t *testing.T) { metaWithoutRank := &DocumentMetadata{ Rank: 0, } got, err := saveDoc(&FieldListWithMeta{ Meta: metaWithoutRank, Fields: searchFields, }) if err != nil { t.Fatalf("saveDoc: %v", err) } want := &pb.Document{ Field: protoFields, OrderId: got.OrderId, OrderIdSource: pb.Document_DEFAULTED.Enum(), } if !proto.Equal(got, want) { t.Errorf("\ngot %v\nwant %v", got, want) } } func TestSaveWithoutMetaUsesDefaultedRank(t *testing.T) { got, err := saveDoc(&FieldListWithMeta{ Fields: searchFields, }) if err != nil { t.Fatalf("saveDoc: %v", err) } want := &pb.Document{ Field: protoFields, OrderId: got.OrderId, OrderIdSource: pb.Document_DEFAULTED.Enum(), } if !proto.Equal(got, want) { t.Errorf("\ngot %v\nwant %v", got, want) } } func TestLoadSaveWithStruct(t *testing.T) { type gopher struct { Name string Info string `search:"about"` Legs float64 `search:",facet"` Fuzz Atom `search:"Fur,facet"` } doc := gopher{"Gopher", "Likes slide rules.", 4, Atom("furry")} pb := &pb.Document{ Field: []*pb.Field{ newStringValueField("Name", "Gopher", pb.FieldValue_TEXT), newStringValueField("about", "Likes slide rules.", pb.FieldValue_TEXT), }, Facet: []*pb.Facet{ newFacet("Legs", "4e+00", pb.FacetValue_NUMBER), newFacet("Fur", "furry", pb.FacetValue_ATOM), }, } var gotDoc gopher if err := loadDoc(&gotDoc, pb, nil); err != nil { t.Fatalf("loadDoc: %v", err) } if !reflect.DeepEqual(gotDoc, doc) { t.Errorf("loading doc\ngot %v\nwant %v", gotDoc, doc) } gotPB, err := saveDoc(&doc) if err != nil { t.Fatalf("saveDoc: %v", err) } gotPB.OrderId = nil // Don't test: it's time dependent. gotPB.OrderIdSource = nil // Don't test because it's contingent on OrderId. if !proto.Equal(gotPB, pb) { t.Errorf("saving doc\ngot %v\nwant %v", gotPB, pb) } } func TestValidFieldNames(t *testing.T) { testCases := []struct { name string valid bool }{ {"Normal", true}, {"Also_OK_123", true}, {"Not so great", false}, {"lower_case", true}, {"Exclaim!", false}, {"Hello세상아 안녕", false}, {"", false}, {"Hεllo", false}, {strings.Repeat("A", 500), true}, {strings.Repeat("A", 501), false}, } for _, tc := range testCases { _, err := saveDoc(&FieldList{ Field{Name: tc.name, Value: "val"}, }) if err != nil && !strings.Contains(err.Error(), "invalid field name") { t.Errorf("unexpected err %q for field name %q", err, tc.name) } if (err == nil) != tc.valid { t.Errorf("field %q: expected valid %t, received err %v", tc.name, tc.valid, err) } } } func TestValidLangs(t *testing.T) { testCases := []struct { field Field valid bool }{ {Field{Name: "Foo", Value: "String", Language: ""}, true}, {Field{Name: "Foo", Value: "String", Language: "en"}, true}, {Field{Name: "Foo", Value: "String", Language: "aussie"}, false}, {Field{Name: "Foo", Value: "String", Language: "12"}, false}, {Field{Name: "Foo", Value: HTML("String"), Language: "en"}, true}, {Field{Name: "Foo", Value: Atom("String"), Language: "en"}, false}, {Field{Name: "Foo", Value: 42, Language: "en"}, false}, } for _, tt := range testCases { _, err := saveDoc(&FieldList{tt.field}) if err == nil != tt.valid { t.Errorf("Field %v, got error %v, wanted valid %t", tt.field, err, tt.valid) } } } func TestDuplicateFields(t *testing.T) { testCases := []struct { desc string fields FieldList errMsg string // Non-empty if we expect an error }{ { desc: "multi string", fields: FieldList{{Name: "FieldA", Value: "val1"}, {Name: "FieldA", Value: "val2"}, {Name: "FieldA", Value: "val3"}}, }, { desc: "multi atom", fields: FieldList{{Name: "FieldA", Value: Atom("val1")}, {Name: "FieldA", Value: Atom("val2")}, {Name: "FieldA", Value: Atom("val3")}}, }, { desc: "mixed", fields: FieldList{{Name: "FieldA", Value: testString}, {Name: "FieldA", Value: testTime}, {Name: "FieldA", Value: float}}, }, { desc: "multi time", fields: FieldList{{Name: "FieldA", Value: testTime}, {Name: "FieldA", Value: testTime}}, errMsg: `duplicate time field "FieldA"`, }, { desc: "multi num", fields: FieldList{{Name: "FieldA", Value: float}, {Name: "FieldA", Value: float}}, errMsg: `duplicate numeric field "FieldA"`, }, } for _, tc := range testCases { _, err := saveDoc(&tc.fields) if (err == nil) != (tc.errMsg == "") || (err != nil && !strings.Contains(err.Error(), tc.errMsg)) { t.Errorf("%s: got err %v, wanted %q", tc.desc, err, tc.errMsg) } } } func TestLoadErrFieldMismatch(t *testing.T) { testCases := []struct { desc string dst interface{} src []*pb.Field err error }{ { desc: "missing", dst: &struct{ One string }{}, src: []*pb.Field{newStringValueField("Two", "woop!", pb.FieldValue_TEXT)}, err: &ErrFieldMismatch{ FieldName: "Two", Reason: "no such struct field", }, }, { desc: "wrong type", dst: &struct{ Num float64 }{}, src: []*pb.Field{newStringValueField("Num", "woop!", pb.FieldValue_TEXT)}, err: &ErrFieldMismatch{ FieldName: "Num", Reason: "type mismatch: float64 for string data", }, }, { desc: "unsettable", dst: &struct{ lower string }{}, src: []*pb.Field{newStringValueField("lower", "woop!", pb.FieldValue_TEXT)}, err: &ErrFieldMismatch{ FieldName: "lower", Reason: "cannot set struct field", }, }, } for _, tc := range testCases { err := loadDoc(tc.dst, &pb.Document{Field: tc.src}, nil) if !reflect.DeepEqual(err, tc.err) { t.Errorf("%s, got err %v, wanted %v", tc.desc, err, tc.err) } } } func TestLimit(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "Search", func(req *pb.SearchRequest, res *pb.SearchResponse) error { limit := 20 // Default per page. if req.Params.Limit != nil { limit = int(*req.Params.Limit) } res.Status = &pb.RequestStatus{Code: pb.SearchServiceError_OK.Enum()} res.MatchedCount = proto.Int64(int64(limit)) for i := 0; i < limit; i++ { res.Result = append(res.Result, &pb.SearchResult{Document: &pb.Document{}}) res.Cursor = proto.String("moreresults") } return nil }) const maxDocs = 500 // Limit maximum number of docs. testCases := []struct { limit, want int }{ {limit: 0, want: maxDocs}, {limit: 42, want: 42}, {limit: 100, want: 100}, {limit: 1000, want: maxDocs}, } for _, tt := range testCases { it := index.Search(c, "gopher", &SearchOptions{Limit: tt.limit, IDsOnly: true}) count := 0 for ; count < maxDocs; count++ { _, err := it.Next(nil) if err == Done { break } if err != nil { t.Fatalf("err after %d: %v", count, err) } } if count != tt.want { t.Errorf("got %d results, expected %d", count, tt.want) } } } func TestPut(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "IndexDocument", func(in *pb.IndexDocumentRequest, out *pb.IndexDocumentResponse) error { expectedIn := &pb.IndexDocumentRequest{ Params: &pb.IndexDocumentParams{ Document: []*pb.Document{ {Field: protoFields, OrderId: proto.Int32(42), OrderIdSource: pb.Document_SUPPLIED.Enum()}, }, IndexSpec: &pb.IndexSpec{ Name: proto.String("Doc"), }, }, } if !proto.Equal(in, expectedIn) { return fmt.Errorf("unsupported argument:\ngot %v\nwant %v", in, expectedIn) } *out = pb.IndexDocumentResponse{ Status: []*pb.RequestStatus{ {Code: pb.SearchServiceError_OK.Enum()}, }, DocId: []string{ "doc_id", }, } return nil }) id, err := index.Put(c, "", &FieldListWithMeta{ Meta: searchMeta, Fields: searchFields, }) if err != nil { t.Fatal(err) } if want := "doc_id"; id != want { t.Errorf("Got doc ID %q, want %q", id, want) } } func TestPutAutoOrderID(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "IndexDocument", func(in *pb.IndexDocumentRequest, out *pb.IndexDocumentResponse) error { if len(in.Params.GetDocument()) < 1 { return fmt.Errorf("expected at least one Document, got %v", in) } got, want := in.Params.Document[0].GetOrderId(), int32(time.Since(orderIDEpoch).Seconds()) if d := got - want; -5 > d || d > 5 { return fmt.Errorf("got OrderId %d, want near %d", got, want) } *out = pb.IndexDocumentResponse{ Status: []*pb.RequestStatus{ {Code: pb.SearchServiceError_OK.Enum()}, }, DocId: []string{ "doc_id", }, } return nil }) if _, err := index.Put(c, "", &searchFields); err != nil { t.Fatal(err) } } func TestPutBadStatus(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "IndexDocument", func(_ *pb.IndexDocumentRequest, out *pb.IndexDocumentResponse) error { *out = pb.IndexDocumentResponse{ Status: []*pb.RequestStatus{ { Code: pb.SearchServiceError_INVALID_REQUEST.Enum(), ErrorDetail: proto.String("insufficient gophers"), }, }, } return nil }) wantErr := "search: INVALID_REQUEST: insufficient gophers" if _, err := index.Put(c, "", &searchFields); err == nil || err.Error() != wantErr { t.Fatalf("Put: got %v error, want %q", err, wantErr) } } func TestPutMultiNilIDSlice(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "IndexDocument", func(in *pb.IndexDocumentRequest, out *pb.IndexDocumentResponse) error { if len(in.Params.GetDocument()) < 1 { return fmt.Errorf("got %v, want at least 1 document", in) } got, want := in.Params.Document[0].GetOrderId(), int32(time.Since(orderIDEpoch).Seconds()) if d := got - want; -5 > d || d > 5 { return fmt.Errorf("got OrderId %d, want near %d", got, want) } *out = pb.IndexDocumentResponse{ Status: []*pb.RequestStatus{ {Code: pb.SearchServiceError_OK.Enum()}, }, DocId: []string{ "doc_id", }, } return nil }) if _, err := index.PutMulti(c, nil, []interface{}{&searchFields}); err != nil { t.Fatal(err) } } func TestPutMultiError(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "IndexDocument", func(in *pb.IndexDocumentRequest, out *pb.IndexDocumentResponse) error { *out = pb.IndexDocumentResponse{ Status: []*pb.RequestStatus{ {Code: pb.SearchServiceError_OK.Enum()}, {Code: pb.SearchServiceError_PERMISSION_DENIED.Enum(), ErrorDetail: proto.String("foo")}, }, DocId: []string{ "id1", "", }, } return nil }) switch _, err := index.PutMulti(c, nil, []interface{}{&searchFields, &searchFields}); { case err == nil: t.Fatalf("got nil, want error") case err.(appengine.MultiError)[0] != nil: t.Fatalf("got %v, want nil MultiError[0]", err.(appengine.MultiError)[0]) case err.(appengine.MultiError)[1] == nil: t.Fatalf("got nil, want not-nill MultiError[1]") } } func TestPutMultiWrongNumberOfIDs(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "IndexDocument", func(in *pb.IndexDocumentRequest, out *pb.IndexDocumentResponse) error { return nil }) if _, err := index.PutMulti(c, []string{"a"}, []interface{}{&searchFields, &searchFields}); err == nil { t.Fatal("got success, want error") } } func TestPutMultiTooManyDocs(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "IndexDocument", func(in *pb.IndexDocumentRequest, out *pb.IndexDocumentResponse) error { return nil }) srcs := make([]interface{}, 201) for i, _ := range srcs { srcs[i] = &searchFields } if _, err := index.PutMulti(c, nil, srcs); err != ErrTooManyDocuments { t.Fatalf("got %v, want ErrTooManyDocuments", err) } } func TestSortOptions(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } noErr := errors.New("") // Sentinel err to return to prevent sending request. testCases := []struct { desc string sort *SortOptions wantSort []*pb.SortSpec wantScorer *pb.ScorerSpec wantErr string }{ { desc: "No SortOptions", }, { desc: "Basic", sort: &SortOptions{ Expressions: []SortExpression{ {Expr: "dog"}, {Expr: "cat", Reverse: true}, {Expr: "gopher", Default: "blue"}, {Expr: "fish", Default: 2.0}, }, Limit: 42, Scorer: MatchScorer, }, wantSort: []*pb.SortSpec{ {SortExpression: proto.String("dog")}, {SortExpression: proto.String("cat"), SortDescending: proto.Bool(false)}, {SortExpression: proto.String("gopher"), DefaultValueText: proto.String("blue")}, {SortExpression: proto.String("fish"), DefaultValueNumeric: proto.Float64(2)}, }, wantScorer: &pb.ScorerSpec{ Limit: proto.Int32(42), Scorer: pb.ScorerSpec_MATCH_SCORER.Enum(), }, }, { desc: "Bad expression default", sort: &SortOptions{ Expressions: []SortExpression{ {Expr: "dog", Default: true}, }, }, wantErr: `search: invalid Default type bool for expression "dog"`, }, { desc: "RescoringMatchScorer", sort: &SortOptions{Scorer: RescoringMatchScorer}, wantScorer: &pb.ScorerSpec{Scorer: pb.ScorerSpec_RESCORING_MATCH_SCORER.Enum()}, }, } for _, tt := range testCases { c := aetesting.FakeSingleContext(t, "search", "Search", func(req *pb.SearchRequest, _ *pb.SearchResponse) error { params := req.Params if !reflect.DeepEqual(params.SortSpec, tt.wantSort) { t.Errorf("%s: params.SortSpec=%v; want %v", tt.desc, params.SortSpec, tt.wantSort) } if !reflect.DeepEqual(params.ScorerSpec, tt.wantScorer) { t.Errorf("%s: params.ScorerSpec=%v; want %v", tt.desc, params.ScorerSpec, tt.wantScorer) } return noErr // Always return some error to prevent response parsing. }) it := index.Search(c, "gopher", &SearchOptions{Sort: tt.sort}) _, err := it.Next(nil) if err == nil { t.Fatalf("%s: err==nil; should not happen", tt.desc) } if err.Error() != tt.wantErr { t.Errorf("%s: got error %q, want %q", tt.desc, err, tt.wantErr) } } } func TestFieldSpec(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } errFoo := errors.New("foo") // sentinel error when there isn't one. testCases := []struct { desc string opts *SearchOptions want *pb.FieldSpec }{ { desc: "No options", want: &pb.FieldSpec{}, }, { desc: "Fields", opts: &SearchOptions{ Fields: []string{"one", "two"}, }, want: &pb.FieldSpec{ Name: []string{"one", "two"}, }, }, { desc: "Expressions", opts: &SearchOptions{ Expressions: []FieldExpression{ {Name: "one", Expr: "price * quantity"}, {Name: "two", Expr: "min(daily_use, 10) * rate"}, }, }, want: &pb.FieldSpec{ Expression: []*pb.FieldSpec_Expression{ {Name: proto.String("one"), Expression: proto.String("price * quantity")}, {Name: proto.String("two"), Expression: proto.String("min(daily_use, 10) * rate")}, }, }, }, } for _, tt := range testCases { c := aetesting.FakeSingleContext(t, "search", "Search", func(req *pb.SearchRequest, _ *pb.SearchResponse) error { params := req.Params if !reflect.DeepEqual(params.FieldSpec, tt.want) { t.Errorf("%s: params.FieldSpec=%v; want %v", tt.desc, params.FieldSpec, tt.want) } return errFoo // Always return some error to prevent response parsing. }) it := index.Search(c, "gopher", tt.opts) if _, err := it.Next(nil); err != errFoo { t.Fatalf("%s: got error %v; want %v", tt.desc, err, errFoo) } } } func TestBasicSearchOpts(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } noErr := errors.New("") // Sentinel err to return to prevent sending request. testCases := []struct { desc string facetOpts []FacetSearchOption cursor Cursor offset int countAccuracy int want *pb.SearchParams wantErr string }{ { desc: "No options", want: &pb.SearchParams{}, }, { desc: "Default auto discovery", facetOpts: []FacetSearchOption{ AutoFacetDiscovery(0, 0), }, want: &pb.SearchParams{ AutoDiscoverFacetCount: proto.Int32(10), }, }, { desc: "Auto discovery", facetOpts: []FacetSearchOption{ AutoFacetDiscovery(7, 12), }, want: &pb.SearchParams{ AutoDiscoverFacetCount: proto.Int32(7), FacetAutoDetectParam: &pb.FacetAutoDetectParam{ ValueLimit: proto.Int32(12), }, }, }, { desc: "Param Depth", facetOpts: []FacetSearchOption{ AutoFacetDiscovery(7, 12), }, want: &pb.SearchParams{ AutoDiscoverFacetCount: proto.Int32(7), FacetAutoDetectParam: &pb.FacetAutoDetectParam{ ValueLimit: proto.Int32(12), }, }, }, { desc: "Doc depth", facetOpts: []FacetSearchOption{ FacetDocumentDepth(123), }, want: &pb.SearchParams{ FacetDepth: proto.Int32(123), }, }, { desc: "Facet discovery", facetOpts: []FacetSearchOption{ FacetDiscovery("colour"), FacetDiscovery("size", Atom("M"), Atom("L")), FacetDiscovery("price", LessThan(7), Range{7, 14}, AtLeast(14)), }, want: &pb.SearchParams{ IncludeFacet: []*pb.FacetRequest{ {Name: proto.String("colour")}, {Name: proto.String("size"), Params: &pb.FacetRequestParam{ ValueConstraint: []string{"M", "L"}, }}, {Name: proto.String("price"), Params: &pb.FacetRequestParam{ Range: []*pb.FacetRange{ {End: proto.String("7e+00")}, {Start: proto.String("7e+00"), End: proto.String("1.4e+01")}, {Start: proto.String("1.4e+01")}, }, }}, }, }, }, { desc: "Facet discovery - bad value", facetOpts: []FacetSearchOption{ FacetDiscovery("colour", true), }, wantErr: "bad FacetSearchOption: unsupported value type bool", }, { desc: "Facet discovery - mix value types", facetOpts: []FacetSearchOption{ FacetDiscovery("colour", Atom("blue"), AtLeast(7)), }, wantErr: "bad FacetSearchOption: values must all be Atom, or must all be Range", }, { desc: "Facet discovery - invalid range", facetOpts: []FacetSearchOption{ FacetDiscovery("colour", Range{negInf, posInf}), }, wantErr: "bad FacetSearchOption: invalid range: either Start or End must be finite", }, { desc: "Cursor", cursor: Cursor("mycursor"), want: &pb.SearchParams{ Cursor: proto.String("mycursor"), }, }, { desc: "Offset", offset: 121, want: &pb.SearchParams{ Offset: proto.Int32(121), }, }, { desc: "Cursor and Offset set", cursor: Cursor("mycursor"), offset: 121, wantErr: "at most one of Cursor and Offset may be specified", }, { desc: "Count accuracy", countAccuracy: 100, want: &pb.SearchParams{ MatchedCountAccuracy: proto.Int32(100), }, }, } for _, tt := range testCases { c := aetesting.FakeSingleContext(t, "search", "Search", func(req *pb.SearchRequest, _ *pb.SearchResponse) error { if tt.want == nil { t.Errorf("%s: expected call to fail", tt.desc) return nil } // Set default fields. tt.want.Query = proto.String("gopher") tt.want.IndexSpec = &pb.IndexSpec{Name: proto.String("Doc")} tt.want.CursorType = pb.SearchParams_PER_RESULT.Enum() tt.want.FieldSpec = &pb.FieldSpec{} if got := req.Params; !reflect.DeepEqual(got, tt.want) { t.Errorf("%s: params=%v; want %v", tt.desc, got, tt.want) } return noErr // Always return some error to prevent response parsing. }) it := index.Search(c, "gopher", &SearchOptions{ Facets: tt.facetOpts, Cursor: tt.cursor, Offset: tt.offset, CountAccuracy: tt.countAccuracy, }) _, err := it.Next(nil) if err == nil { t.Fatalf("%s: err==nil; should not happen", tt.desc) } if err.Error() != tt.wantErr { t.Errorf("%s: got error %q, want %q", tt.desc, err, tt.wantErr) } } } func TestFacetRefinements(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } noErr := errors.New("") // Sentinel err to return to prevent sending request. testCases := []struct { desc string refine []Facet want []*pb.FacetRefinement wantErr string }{ { desc: "No refinements", }, { desc: "Basic", refine: []Facet{ {Name: "fur", Value: Atom("fluffy")}, {Name: "age", Value: LessThan(123)}, {Name: "age", Value: AtLeast(0)}, {Name: "legs", Value: Range{Start: 3, End: 5}}, }, want: []*pb.FacetRefinement{ {Name: proto.String("fur"), Value: proto.String("fluffy")}, {Name: proto.String("age"), Range: &pb.FacetRefinement_Range{End: proto.String("1.23e+02")}}, {Name: proto.String("age"), Range: &pb.FacetRefinement_Range{Start: proto.String("0e+00")}}, {Name: proto.String("legs"), Range: &pb.FacetRefinement_Range{Start: proto.String("3e+00"), End: proto.String("5e+00")}}, }, }, { desc: "Infinite range", refine: []Facet{ {Name: "age", Value: Range{Start: negInf, End: posInf}}, }, wantErr: `search: refinement for facet "age": either Start or End must be finite`, }, { desc: "Bad End value in range", refine: []Facet{ {Name: "age", Value: LessThan(2147483648)}, }, wantErr: `search: refinement for facet "age": invalid value for End`, }, { desc: "Bad Start value in range", refine: []Facet{ {Name: "age", Value: AtLeast(-2147483649)}, }, wantErr: `search: refinement for facet "age": invalid value for Start`, }, { desc: "Unknown value type", refine: []Facet{ {Name: "age", Value: "you can't use strings!"}, }, wantErr: `search: unsupported refinement for facet "age" of type string`, }, } for _, tt := range testCases { c := aetesting.FakeSingleContext(t, "search", "Search", func(req *pb.SearchRequest, _ *pb.SearchResponse) error { if got := req.Params.FacetRefinement; !reflect.DeepEqual(got, tt.want) { t.Errorf("%s: params.FacetRefinement=%v; want %v", tt.desc, got, tt.want) } return noErr // Always return some error to prevent response parsing. }) it := index.Search(c, "gopher", &SearchOptions{Refinements: tt.refine}) _, err := it.Next(nil) if err == nil { t.Fatalf("%s: err==nil; should not happen", tt.desc) } if err.Error() != tt.wantErr { t.Errorf("%s: got error %q, want %q", tt.desc, err, tt.wantErr) } } } func TestNamespaceResetting(t *testing.T) { namec := make(chan *string, 1) c0 := aetesting.FakeSingleContext(t, "search", "IndexDocument", func(req *pb.IndexDocumentRequest, res *pb.IndexDocumentResponse) error { namec <- req.Params.IndexSpec.Namespace return fmt.Errorf("RPC error") }) // Check that wrapping c0 in a namespace twice works correctly. c1, err := appengine.Namespace(c0, "A") if err != nil { t.Fatalf("appengine.Namespace: %v", err) } c2, err := appengine.Namespace(c1, "") // should act as the original context if err != nil { t.Fatalf("appengine.Namespace: %v", err) } i := (&Index{}) i.Put(c0, "something", &searchDoc) if ns := <-namec; ns != nil { t.Errorf(`Put with c0: ns = %q, want nil`, *ns) } i.Put(c1, "something", &searchDoc) if ns := <-namec; ns == nil { t.Error(`Put with c1: ns = nil, want "A"`) } else if *ns != "A" { t.Errorf(`Put with c1: ns = %q, want "A"`, *ns) } i.Put(c2, "something", &searchDoc) if ns := <-namec; ns != nil { t.Errorf(`Put with c2: ns = %q, want nil`, *ns) } } func TestDelete(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "DeleteDocument", func(in *pb.DeleteDocumentRequest, out *pb.DeleteDocumentResponse) error { expectedIn := &pb.DeleteDocumentRequest{ Params: &pb.DeleteDocumentParams{ DocId: []string{"id"}, IndexSpec: &pb.IndexSpec{Name: proto.String("Doc")}, }, } if !proto.Equal(in, expectedIn) { return fmt.Errorf("unsupported argument:\ngot %v\nwant %v", in, expectedIn) } *out = pb.DeleteDocumentResponse{ Status: []*pb.RequestStatus{ {Code: pb.SearchServiceError_OK.Enum()}, }, } return nil }) if err := index.Delete(c, "id"); err != nil { t.Fatal(err) } } func TestDeleteMulti(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "DeleteDocument", func(in *pb.DeleteDocumentRequest, out *pb.DeleteDocumentResponse) error { expectedIn := &pb.DeleteDocumentRequest{ Params: &pb.DeleteDocumentParams{ DocId: []string{"id1", "id2"}, IndexSpec: &pb.IndexSpec{Name: proto.String("Doc")}, }, } if !proto.Equal(in, expectedIn) { return fmt.Errorf("unsupported argument:\ngot %v\nwant %v", in, expectedIn) } *out = pb.DeleteDocumentResponse{ Status: []*pb.RequestStatus{ {Code: pb.SearchServiceError_OK.Enum()}, {Code: pb.SearchServiceError_OK.Enum()}, }, } return nil }) if err := index.DeleteMulti(c, []string{"id1", "id2"}); err != nil { t.Fatal(err) } } func TestDeleteWrongNumberOfResults(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "DeleteDocument", func(in *pb.DeleteDocumentRequest, out *pb.DeleteDocumentResponse) error { expectedIn := &pb.DeleteDocumentRequest{ Params: &pb.DeleteDocumentParams{ DocId: []string{"id1", "id2"}, IndexSpec: &pb.IndexSpec{Name: proto.String("Doc")}, }, } if !proto.Equal(in, expectedIn) { return fmt.Errorf("unsupported argument:\ngot %v\nwant %v", in, expectedIn) } *out = pb.DeleteDocumentResponse{ Status: []*pb.RequestStatus{ {Code: pb.SearchServiceError_OK.Enum()}, }, } return nil }) if err := index.DeleteMulti(c, []string{"id1", "id2"}); err == nil { t.Fatalf("got nil, want error") } } func TestDeleteMultiError(t *testing.T) { index, err := Open("Doc") if err != nil { t.Fatalf("err from Open: %v", err) } c := aetesting.FakeSingleContext(t, "search", "DeleteDocument", func(in *pb.DeleteDocumentRequest, out *pb.DeleteDocumentResponse) error { expectedIn := &pb.DeleteDocumentRequest{ Params: &pb.DeleteDocumentParams{ DocId: []string{"id1", "id2"}, IndexSpec: &pb.IndexSpec{Name: proto.String("Doc")}, }, } if !proto.Equal(in, expectedIn) { return fmt.Errorf("unsupported argument:\ngot %v\nwant %v", in, expectedIn) } *out = pb.DeleteDocumentResponse{ Status: []*pb.RequestStatus{ {Code: pb.SearchServiceError_OK.Enum()}, {Code: pb.SearchServiceError_PERMISSION_DENIED.Enum(), ErrorDetail: proto.String("foo")}, }, } return nil }) switch err := index.DeleteMulti(c, []string{"id1", "id2"}); { case err == nil: t.Fatalf("got nil, want error") case err.(appengine.MultiError)[0] != nil: t.Fatalf("got %v, want nil MultiError[0]", err.(appengine.MultiError)[0]) case err.(appengine.MultiError)[1] == nil: t.Fatalf("got nil, want not-nill MultiError[1]") } }