diff --git a/rueidiscompatmock/README.md b/rueidiscompatmock/README.md index 3d9722ea..228b51c9 100644 --- a/rueidiscompatmock/README.md +++ b/rueidiscompatmock/README.md @@ -46,4 +46,35 @@ p := rdb.Pipeline() p.Get(ctx, "k1") p.Set(ctx, "k2", "v2", 0) p.Exec(ctx) +``` + +### Client-side caching example + +Cached commands (`Cache(ttl)`) use the same `Expect*` calls as non-cached +commands, but expectation matching is origin-aware: a call made through +`Cache(ttl)` only matches an expectation marked `ViaCache()`, and a plain call +only matches an expectation without it. The TTL argument itself is not +matched against expectations. + +```golang +compatmock.ExpectGet("key").ViaCache().SetVal("val") + +cached := rdb.Cache(100 * time.Millisecond) +cached.Get(context.Background(), "key") // matches + +rdb.Get(context.Background(), "key") // would NOT match the ViaCache() expectation +``` + +The reverse also holds: an expectation without `ViaCache()` only matches a +plain call and will not be satisfied by a call routed through `Cache(ttl)`. +`ExpectationsWereMet()` reports an unmatched expectation when the two origins +are mixed. + +```golang +compatmock.ExpectGet("key").SetVal("val") + +rdb.Get(context.Background(), "key") // matches + +cached := rdb.Cache(100 * time.Millisecond) +cached.Get(context.Background(), "key") // would NOT match the plain expectation ``` \ No newline at end of file diff --git a/rueidiscompatmock/adapter.go b/rueidiscompatmock/adapter.go index c31c04bc..b9f0caeb 100644 --- a/rueidiscompatmock/adapter.go +++ b/rueidiscompatmock/adapter.go @@ -369,16 +369,17 @@ type clientMock struct { } type expectation struct { - matcher gomock.Matcher - expected []string - result rueidis.RedisResult - rawResult any - err error - resultSet bool - rawSet bool - redisNil bool - regexpMatch bool - customMatch CustomMatch + matcher gomock.Matcher + expected []string + result rueidis.RedisResult + rawResult any + err error + resultSet bool + rawSet bool + redisNil bool + regexpMatch bool + customMatch CustomMatch + requireCache bool } type resolvedExpectation struct { @@ -553,25 +554,38 @@ func (m *clientMock) wire() { m.raw.EXPECT(). Do(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, cmd rueidis.Completed) rueidis.RedisResult { - return m.consume(1, []rueidis.Completed{cmd})[0] + return m.consume(1, []rueidis.Completed{cmd}, false)[0] }). AnyTimes() m.raw.EXPECT(). DoMulti(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, cmds ...rueidis.Completed) []rueidis.RedisResult { - return m.consume(len(cmds), cmds) + return m.consume(len(cmds), cmds, false) }). AnyTimes() + m.raw.EXPECT(). + DoCache(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, cmd rueidis.Cacheable, _ time.Duration) rueidis.RedisResult { + return m.consume(1, []rueidis.Completed{rueidis.Completed(cmd)}, true)[0] + }). + AnyTimes() +} + +func unmatchedErr(cmd rueidis.Completed, viaCache bool) error { + if viaCache { + return fmt.Errorf("rueidiscompatmock: no expectation for command %v via cache", cmd.Commands()) + } + return fmt.Errorf("rueidiscompatmock: no expectation for command %v", cmd.Commands()) } -func (m *clientMock) consume(n int, cmds []rueidis.Completed) []rueidis.RedisResult { +func (m *clientMock) consume(n int, cmds []rueidis.Completed, viaCache bool) []rueidis.RedisResult { m.mu.Lock() defer m.mu.Unlock() out := make([]rueidis.RedisResult, n) for i, cmd := range cmds { - idx, ok := m.matchLocked(cmd) + idx, ok := m.matchLocked(cmd, viaCache) if !ok { - err := fmt.Errorf("rueidiscompatmock: no expectation for command %v", cmd.Commands()) + err := unmatchedErr(cmd, viaCache) m.unmatched = append(m.unmatched, err) out[i] = mock.ErrorResult(err) for j := i + 1; j < n; j++ { @@ -588,9 +602,9 @@ func (m *clientMock) consume(n int, cmds []rueidis.Completed) []rueidis.RedisRes func (m *clientMock) consumeWithRaw(cmd rueidis.Completed) resolvedExpectation { m.mu.Lock() defer m.mu.Unlock() - idx, ok := m.matchLocked(cmd) + idx, ok := m.matchLocked(cmd, false) if !ok { - err := fmt.Errorf("rueidiscompatmock: no expectation for command %v", cmd.Commands()) + err := unmatchedErr(cmd, false) m.unmatched = append(m.unmatched, err) return resolvedExpectation{result: mock.ErrorResult(err)} } @@ -604,25 +618,28 @@ func (m *clientMock) consumeWithRaw(cmd rueidis.Completed) resolvedExpectation { return resolved } -func (m *clientMock) matchLocked(cmd rueidis.Completed) (int, bool) { +func (m *clientMock) matchLocked(cmd rueidis.Completed, viaCache bool) (int, bool) { if m.ordered { if len(m.queue) == 0 { return 0, false } - if !m.queue[0].matches(cmd) { + if !m.queue[0].matches(cmd, viaCache) { return 0, false } return 0, true } for i, e := range m.queue { - if e.matches(cmd) { + if e.matches(cmd, viaCache) { return i, true } } return 0, false } -func (e *expectation) matches(cmd rueidis.Completed) bool { +func (e *expectation) matches(cmd rueidis.Completed, viaCache bool) bool { + if e.requireCache != viaCache { + return false + } if e.customMatch == nil && !e.regexpMatch { return e.matcher.Matches(cmd) } diff --git a/rueidiscompatmock/adapter_test.go b/rueidiscompatmock/adapter_test.go index dbcf0a85..58199e8c 100644 --- a/rueidiscompatmock/adapter_test.go +++ b/rueidiscompatmock/adapter_test.go @@ -3066,6 +3066,258 @@ func wantAnyErr(err error) error { return nil } +func TestCacheCompatViaNewAdapter(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + ctx := context.Background() + raw := mock.NewClient(ctrl) + cm := NewAdapter(raw) + rdb := rueidiscompat.NewAdapter(raw) + + cm.ExpectGet("key").ViaCache().SetVal("v") + cmd := rdb.Cache(time.Second).Get(ctx, "key") + if got := cmd.Val(); got != "v" { + t.Fatalf("expected %q, got %q", "v", got) + } + if err := cm.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected err %v", err) + } +} + +func TestCacheCompatMultiCommand(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + cm.ExpectMGet("k1", "k2").ViaCache().SetVal([]any{"v1", "v2"}) + cm.ExpectHGetAll("h").ViaCache().SetVal(map[string]string{"f": "v"}) + cm.ExpectGetRange("s", 0, 1).ViaCache().SetVal("ab") + + cache := rdb.Cache(time.Minute) + if got := cache.MGet(ctx, "k1", "k2").Val(); !reflect.DeepEqual(got, []any{"v1", "v2"}) { + t.Fatalf("unexpected MGet result %v", got) + } + if got := cache.HGetAll(ctx, "h").Val(); !reflect.DeepEqual(got, map[string]string{"f": "v"}) { + t.Fatalf("unexpected HGetAll result %v", got) + } + if got := cache.GetRange(ctx, "s", 0, 1).Val(); got != "ab" { + t.Fatalf("unexpected GetRange result %q", got) + } + if err := cm.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected err %v", err) + } +} + +func TestCacheCompatCacheableWrapped(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + cm.ExpectEvalRO("return 1", []string{}).ViaCache().SetValInt(1) + cmd := rdb.Cache(time.Second).EvalRO(ctx, "return 1", []string{}) + if got := cmd.Val(); got != int64(1) { + t.Fatalf("unexpected EvalRO result %v", got) + } + if err := cm.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected err %v", err) + } +} + +func TestCacheCompatRedisNil(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + cm.ExpectGet("key").ViaCache().RedisNil() + if err := rdb.Cache(time.Second).Get(ctx, "key").Err(); !errors.Is(err, rueidiscompat.Nil) { + t.Fatalf("expected Nil, got %v", err) + } + if err := cm.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected err %v", err) + } +} + +func TestCacheCompatSetErr(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + cm.ExpectGet("key").ViaCache().SetErr(errors.New("any")) + if err := wantAnyErr(rdb.Cache(time.Second).Get(ctx, "key").Err()); err != nil { + t.Fatal(err) + } + if err := cm.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected err %v", err) + } +} + +func TestCacheCompatTTLIrrelevant(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + cm.ExpectGet("k").ViaCache().SetVal("a") + cm.ExpectGet("k").ViaCache().SetVal("b") + if got := rdb.Cache(time.Millisecond).Get(ctx, "k").Val(); got != "a" { + t.Fatalf("expected %q, got %q", "a", got) + } + if got := rdb.Cache(time.Hour).Get(ctx, "k").Val(); got != "b" { + t.Fatalf("expected %q, got %q", "b", got) + } + if err := cm.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected err %v", err) + } +} + +func TestCacheCompatUnmatched(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + err := rdb.Cache(time.Second).Get(ctx, "key").Err() + if err == nil || !strings.Contains(err.Error(), "no expectation for command") || !strings.Contains(err.Error(), "via cache") { + t.Fatalf(`expected no-expectation error mentioning "via cache", got %v`, err) + } + if err := cm.ExpectationsWereMet(); err == nil { + t.Fatal("expected unmatched expectation error") + } +} + +func TestCacheCompatUnmatchedPlainCallOmitsViaCache(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + err := rdb.Get(ctx, "key").Err() + if err == nil || !strings.Contains(err.Error(), "no expectation for command") || strings.Contains(err.Error(), "via cache") { + t.Fatalf(`expected no-expectation error without "via cache", got %v`, err) + } + if err := cm.ExpectationsWereMet(); err == nil { + t.Fatal("expected unmatched expectation error") + } +} + +func TestCacheCompatViaCacheRequired(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + cm.ExpectGet("key").ViaCache().SetVal("v") + if got := rdb.Cache(time.Second).Get(ctx, "key").Val(); got != "v" { + t.Fatalf("expected %q, got %q", "v", got) + } + if err := cm.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected err %v", err) + } +} + +func TestCacheCompatViaCacheRejectsPlainCall(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + cm.ExpectGet("key").ViaCache().SetVal("v") + err := rdb.Get(ctx, "key").Err() + if err == nil || !strings.Contains(err.Error(), "no expectation for command") { + t.Fatalf("expected no-expectation error for plain call against a ViaCache expectation, got %v", err) + } + if err := cm.ExpectationsWereMet(); err == nil { + t.Fatal("expected the ViaCache expectation to remain unmatched") + } +} + +func TestCacheCompatViaCacheUnordered(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + raw := mock.NewClient(ctrl) + cm := NewAdapter(raw) + rdb := rueidiscompat.NewAdapter(raw) + cm.MatchExpectationsInOrder(false) + + // Two expectations for the same key: one plain, one cache-required. In + // unordered mode the scan must pick the one matching the call's origin, + // not just the first one for the command/key. + cm.ExpectGet("key").ViaCache().SetVal("cached") + cm.ExpectGet("key").SetVal("plain") + + if got := rdb.Get(ctx, "key").Val(); got != "plain" { + t.Fatalf("expected plain call to match the non-cache expectation, got %q", got) + } + if got := rdb.Cache(time.Second).Get(ctx, "key").Val(); got != "cached" { + t.Fatalf("expected cached call to match the ViaCache expectation, got %q", got) + } + if err := cm.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected err %v", err) + } +} + +func TestCacheCompatViaCacheUnorderedGenericQueuedFirst(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + raw := mock.NewClient(ctrl) + cm := NewAdapter(raw) + rdb := rueidiscompat.NewAdapter(raw) + cm.MatchExpectationsInOrder(false) + + // Same as TestCacheCompatViaCacheUnordered but with queue order reversed: + // the unmarked (generic) expectation is queued BEFORE the ViaCache() + // expectation. A cached call must still prefer the cache-specific entry + // rather than "stealing" the generic one queued ahead of it. + cm.ExpectGet("key").SetVal("plain") + cm.ExpectGet("key").ViaCache().SetVal("cached") + + if got := rdb.Cache(time.Second).Get(ctx, "key").Val(); got != "cached" { + t.Fatalf("expected cached call to match the ViaCache expectation, got %q", got) + } + if got := rdb.Get(ctx, "key").Val(); got != "plain" { + t.Fatalf("expected the remaining plain call to match the unmarked expectation, got %q", got) + } + if err := cm.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected err %v", err) + } +} + +func TestCacheCompatViaCacheUnorderedRequiresViaCache(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + raw := mock.NewClient(ctrl) + cm := NewAdapter(raw) + rdb := rueidiscompat.NewAdapter(raw) + cm.MatchExpectationsInOrder(false) + + // No cache-specific expectation is queued at all; a cached call must not + // fall back to the unmarked expectation for the same command. + cm.ExpectGet("key").SetVal("plain") + + err := rdb.Cache(time.Second).Get(ctx, "key").Err() + if err == nil || !strings.Contains(err.Error(), "no expectation for command") { + t.Fatalf("expected no-expectation error for cached call against only an unmarked expectation, got %v", err) + } + if err := cm.ExpectationsWereMet(); err == nil { + t.Fatal("expected the unmarked expectation to remain unmatched") + } +} + +func TestCacheCompatUnmarkedMatchesPlainOnly(t *testing.T) { + ctx := context.Background() + rdb, cm := NewClientMock() + defer rdb.Close() + + cm.ExpectGet("k1").SetVal("plain") + if got := rdb.Get(ctx, "k1").Val(); got != "plain" { + t.Fatalf("expected %q, got %q", "plain", got) + } + + cm.ExpectGet("k2").SetVal("plain") + err := rdb.Cache(time.Second).Get(ctx, "k2").Err() + if err == nil || !strings.Contains(err.Error(), "no expectation for command") { + t.Fatalf("expected no-expectation error for cached call against an unmarked expectation, got %v", err) + } + if err := cm.ExpectationsWereMet(); err == nil { + t.Fatal("expected the unmarked expectation for k2 to remain unmatched") + } +} + func runCases(t *testing.T, cm ClientMock, cases []func() error) { t.Helper() for i, c := range cases { diff --git a/rueidiscompatmock/viacache.go b/rueidiscompatmock/viacache.go new file mode 100644 index 00000000..530fb678 --- /dev/null +++ b/rueidiscompatmock/viacache.go @@ -0,0 +1,226 @@ +package rueidiscompatmock + +// ViaCache marks this expectation so it is only satisfied by a call routed +// through DoCache (i.e. rueidiscompat.Cache(ttl).()). A plain, non-cached +// call for the same command will not match it. Expectations that do not call +// ViaCache match only plain calls and will not be satisfied by a call routed +// through DoCache. +func (e *ExpectedString) ViaCache() *ExpectedString { + e.exp.requireCache = true + return e +} + +func (e *ExpectedBool) ViaCache() *ExpectedBool { + e.exp.requireCache = true + return e +} + +func (e *ExpectedInt) ViaCache() *ExpectedInt { + e.exp.requireCache = true + return e +} + +func (e *ExpectedDuration) ViaCache() *ExpectedDuration { + e.exp.requireCache = true + return e +} + +func (e *ExpectedFloat) ViaCache() *ExpectedFloat { + e.exp.requireCache = true + return e +} + +func (e *ExpectedStringSlice) ViaCache() *ExpectedStringSlice { + e.exp.requireCache = true + return e +} + +func (e *ExpectedIntSlice) ViaCache() *ExpectedIntSlice { + e.exp.requireCache = true + return e +} + +func (e *ExpectedBoolSlice) ViaCache() *ExpectedBoolSlice { + e.exp.requireCache = true + return e +} + +func (e *ExpectedFloatSlice) ViaCache() *ExpectedFloatSlice { + e.exp.requireCache = true + return e +} + +func (e *ExpectedSlice) ViaCache() *ExpectedSlice { + e.exp.requireCache = true + return e +} + +func (e *ExpectedStringStringMap) ViaCache() *ExpectedStringStringMap { + e.exp.requireCache = true + return e +} + +func (e *ExpectedStringIntMap) ViaCache() *ExpectedStringIntMap { + e.exp.requireCache = true + return e +} + +func (e *ExpectedStringStructMap) ViaCache() *ExpectedStringStructMap { + e.exp.requireCache = true + return e +} + +func (e *ExpectedKeyValueSlice) ViaCache() *ExpectedKeyValueSlice { + e.exp.requireCache = true + return e +} + +func (e *ExpectedKeyValues) ViaCache() *ExpectedKeyValues { + e.exp.requireCache = true + return e +} + +func (e *ExpectedZSlice) ViaCache() *ExpectedZSlice { + e.exp.requireCache = true + return e +} + +func (e *ExpectedZWithKey) ViaCache() *ExpectedZWithKey { + e.exp.requireCache = true + return e +} + +func (e *ExpectedRankWithScore) ViaCache() *ExpectedRankWithScore { + e.exp.requireCache = true + return e +} + +func (e *ExpectedTime) ViaCache() *ExpectedTime { + e.exp.requireCache = true + return e +} + +func (e *ExpectedScan) ViaCache() *ExpectedScan { + e.exp.requireCache = true + return e +} + +func (e *ExpectedGeoPos) ViaCache() *ExpectedGeoPos { + e.exp.requireCache = true + return e +} + +func (e *ExpectedGeoLocation) ViaCache() *ExpectedGeoLocation { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXMessageSlice) ViaCache() *ExpectedXMessageSlice { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXStreamSlice) ViaCache() *ExpectedXStreamSlice { + e.exp.requireCache = true + return e +} + +func (e *ExpectedCmd) ViaCache() *ExpectedCmd { + e.exp.requireCache = true + return e +} + +func (e *ExpectedCommandsInfo) ViaCache() *ExpectedCommandsInfo { + e.exp.requireCache = true + return e +} + +func (e *ExpectedKeyFlags) ViaCache() *ExpectedKeyFlags { + e.exp.requireCache = true + return e +} + +func (e *ExpectedSlowLog) ViaCache() *ExpectedSlowLog { + e.exp.requireCache = true + return e +} + +func (e *ExpectedOKBool) ViaCache() *ExpectedOKBool { + e.exp.requireCache = true + return e +} + +func (e *ExpectedClusterSlots) ViaCache() *ExpectedClusterSlots { + e.exp.requireCache = true + return e +} + +func (e *ExpectedClusterShards) ViaCache() *ExpectedClusterShards { + e.exp.requireCache = true + return e +} + +func (e *ExpectedClusterLinks) ViaCache() *ExpectedClusterLinks { + e.exp.requireCache = true + return e +} + +func (e *ExpectedLCS) ViaCache() *ExpectedLCS { + e.exp.requireCache = true + return e +} + +func (e *ExpectedZSliceWithKey) ViaCache() *ExpectedZSliceWithKey { + e.exp.requireCache = true + return e +} + +func (e *ExpectedScriptExists) ViaCache() *ExpectedScriptExists { + e.exp.requireCache = true + return e +} + +func (e *ExpectedFunctionList) ViaCache() *ExpectedFunctionList { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXAutoClaim) ViaCache() *ExpectedXAutoClaim { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXAutoClaimJustID) ViaCache() *ExpectedXAutoClaimJustID { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXInfoConsumers) ViaCache() *ExpectedXInfoConsumers { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXInfoGroups) ViaCache() *ExpectedXInfoGroups { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXInfoStream) ViaCache() *ExpectedXInfoStream { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXInfoStreamFull) ViaCache() *ExpectedXInfoStreamFull { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXPending) ViaCache() *ExpectedXPending { + e.exp.requireCache = true + return e +} + +func (e *ExpectedXPendingExt) ViaCache() *ExpectedXPendingExt { + e.exp.requireCache = true + return e +}