package milergeo import ( "context" "errors" "strings" "testing" "github.com/redis/go-redis/v9" ) // fakeRedis answers the three geo commands the way a given Redis would. type fakeRedis struct { searchErr error // GEOSEARCH result; nil = supported radiusErr error addErr error locs []redis.GeoLocation searchHits int radiusHits int } func (f *fakeRedis) GeoSearchLocation(ctx context.Context, _ string, q *redis.GeoSearchLocationQuery) *redis.GeoSearchLocationCmd { f.searchHits++ cmd := redis.NewGeoSearchLocationCmd(ctx, q) if f.searchErr != nil { cmd.SetErr(f.searchErr) } else { cmd.SetVal(f.locs) } return cmd } func (f *fakeRedis) GeoRadius(_ context.Context, _ string, _, _ float64, _ *redis.GeoRadiusQuery) *redis.GeoLocationCmd { f.radiusHits++ return redis.NewGeoLocationCmdResult(f.locs, f.radiusErr) } func (f *fakeRedis) GeoAdd(ctx context.Context, _ string, _ ...*redis.GeoLocation) *redis.IntCmd { cmd := redis.NewIntCmd(ctx) if f.addErr != nil { cmd.SetErr(f.addErr) } else { cmd.SetVal(1) } return cmd } var nearby = []redis.GeoLocation{{Name: "38", Dist: 0.03}, {Name: "23", Dist: 0.4}} func TestSearchUsesGeosearchOnRedis62(t *testing.T) { legacyOnly.Store(false) f := &fakeRedis{locs: nearby} got, err := Search(context.Background(), f, 11.0053, 76.9511, 10, 10) if err != nil || len(got) != 2 || got[0].Name != "38" { t.Fatalf("got %v, %v", got, err) } if f.searchHits != 1 || f.radiusHits != 0 { t.Fatalf("GEOSEARCH must be used when supported: search=%d radius=%d", f.searchHits, f.radiusHits) } } // The production failure: Redis < 6.2 has no GEOSEARCH, so every order found // "no riders". The fallback must find them, and stop retrying GEOSEARCH. func TestSearchFallsBackOnOldRedis(t *testing.T) { legacyOnly.Store(false) t.Cleanup(func() { legacyOnly.Store(false) }) f := &fakeRedis{searchErr: errors.New("ERR unknown command 'GEOSEARCH', with args beginning with: 'milers:locations'"), locs: nearby} for i := 0; i < 3; i++ { got, err := Search(context.Background(), f, 11.0053, 76.9511, 10, 10) if err != nil || len(got) != 2 { t.Fatalf("call %d: got %v, %v", i, got, err) } } if f.searchHits != 1 || f.radiusHits != 3 { t.Fatalf("after the first rejection only GEORADIUS should run: search=%d radius=%d", f.searchHits, f.radiusHits) } if p := Probe(context.Background(), f); !strings.Contains(p, "GEORADIUS fallback") { t.Fatalf("probe = %q", p) } } // Any other failure must surface, not read as an empty street. func TestSearchReturnsOtherErrors(t *testing.T) { legacyOnly.Store(false) f := &fakeRedis{searchErr: errors.New("WRONGTYPE Operation against a key holding the wrong kind of value")} if _, err := Search(context.Background(), f, 11, 76, 10, 10); err == nil || !strings.Contains(err.Error(), "WRONGTYPE") { t.Fatalf("want the WRONGTYPE error, got %v", err) } if f.radiusHits != 0 || legacyOnly.Load() { t.Fatal("a non-'unknown command' error must not switch to the fallback") } if p := Probe(context.Background(), f); !strings.HasPrefix(p, "error: ") { t.Fatalf("probe = %q", p) } } func TestIndexReportsWriteFailure(t *testing.T) { if err := Index(context.Background(), &fakeRedis{}, 38, 11.0, 76.9); err != nil { t.Fatalf("ok write: %v", err) } err := Index(context.Background(), &fakeRedis{addErr: errors.New("WRONGTYPE Operation")}, 38, 11.0, 76.9) if err == nil || !strings.Contains(err.Error(), "WRONGTYPE") { t.Fatalf("want the write error, got %v", err) } } // db.Rdb is a *redis.Client; before InitRedis it is a nil pointer, which in // an interface is not == nil. It must be refused, not dereferenced. func TestNilClientIsRefused(t *testing.T) { var rdb *redis.Client if _, err := Search(context.Background(), rdb, 11, 76, 10, 10); err == nil { t.Fatal("nil client must be an error") } if err := Index(context.Background(), rdb, 1, 11, 76); err == nil { t.Fatal("nil client must be an error") } if p := Probe(context.Background(), rdb); !strings.HasPrefix(p, "error") { t.Fatalf("probe = %q", p) } }