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 remErr error removed []interface{} } func (f *fakeRedis) ZRem(ctx context.Context, _ string, members ...interface{}) *redis.IntCmd { f.removed = append(f.removed, members...) cmd := redis.NewIntCmd(ctx) if f.remErr != nil { cmd.SetErr(f.remErr) } else { cmd.SetVal(int64(len(members))) } return cmd } 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) } } // A GEO member never expires, and nothing removed one when a rider went off // duty — so milers:locations kept every rider who had ever started a shift, // frozen where they last reported. Assignment takes the N NEAREST members, so // those stale entries crowded out riders who were actually working and the // candidate pool collapsed to whoever survived the freshness filter. func TestRemoveDropsTheRiderFromTheSet(t *testing.T) { f := &fakeRedis{} if err := Remove(context.Background(), f, 412); err != nil { t.Fatalf("Remove: %v", err) } if len(f.removed) != 1 || f.removed[0] != "412" { t.Errorf("removed = %v, want [\"412\"]", f.removed) } } // The member is the rider's userid as a DECIMAL STRING — the same spelling // GeoAdd writes and Search reads back. A mismatch here would remove nothing // and report success. func TestRemoveUsesTheSameMemberSpellingAsAdd(t *testing.T) { f := &fakeRedis{} _ = Remove(context.Background(), f, 7) if f.removed[0] != "7" { t.Errorf("member = %v, want \"7\" (decimal string, not an int)", f.removed[0]) } } // Best-effort at the call site: the rider is off duty either way, and the // freshness check still excludes them. The error must reach the caller so it // can be logged rather than swallowed here. func TestRemoveReturnsTheError(t *testing.T) { f := &fakeRedis{remErr: errors.New("redis down")} if err := Remove(context.Background(), f, 1); err == nil { t.Error("Remove swallowed the error") } } func TestRemoveOnNilClientIsANoOp(t *testing.T) { if err := Remove(context.Background(), nil, 1); err != nil { t.Errorf("Remove(nil) = %v, want nil", err) } }