Ctrl K
ComunidadGuías…

cache/redis_cache_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cache

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"testing"
	"time"

	"github.com/go-redis/redis/v8"
)

// --- test value type ---

type testItem struct {
	ID    int64
	Value string
}

func (t *testItem) Identifier() int64 { return t.ID }

// --- codec ---

type testCodec struct{}

func (testCodec) Encode(v *testItem) string {
	return fmt.Sprintf("%d:%s", v.ID, v.Value)
}

func (testCodec) Decode(s string) (*testItem, error) {
	for i, c := range s {
		if c == ':' {
			id, err := strconv.ParseInt(s[:i], 10, 64)
			if err != nil {
				return nil, err
			}
			return &testItem{ID: id, Value: s[i+1:]}, nil
		}
	}
	return nil, fmt.Errorf("invalid format: %s", s)
}

// --- mock extended getter ---

type mockExtGetter struct {
	findFn     func(ctx context.Context, key int64) (*testItem, error)
	findManyFn func(ctx context.Context, keys []int64) ([]*testItem, error)
}

func (m *mockExtGetter) Find(ctx context.Context, key int64) (*testItem, error) {
	return m.findFn(ctx, key)
}

func (m *mockExtGetter) FindMany(ctx context.Context, keys []int64) ([]*testItem, error) {
	return m.findManyFn(ctx, keys)
}

// --- mock pipeline (supports pipelined GET and SET) ---

type mockPipeline struct {
	redis.Pipeliner
	// getResults maps encoded key -> value. nil value means redis.Nil (miss).
	getResults map[string]*string
	getCalls   []string
	setCalls   []string
	execErr    error
}

func (p *mockPipeline) Get(ctx context.Context, key string) *redis.StringCmd {
	p.getCalls = append(p.getCalls, key)
	cmd := redis.NewStringCmd(ctx)
	if p.getResults != nil {
		if val, ok := p.getResults[key]; ok && val != nil {
			cmd.SetVal(*val)
			return cmd
		}
	}
	cmd.SetErr(redis.Nil)
	return cmd
}

func (p *mockPipeline) Set(ctx context.Context, key string, _ interface{}, _ time.Duration) *redis.StatusCmd {
	p.setCalls = append(p.setCalls, key)
	return redis.NewStatusCmd(ctx)
}

func (p *mockPipeline) Exec(_ context.Context) ([]redis.Cmder, error) {
	return nil, p.execErr
}

// --- mock redis client (supports multiple Pipeline() calls) ---

type mockRedisClient struct {
	redis.UniversalClient
	getResult string
	getErr    error
	setErr    error
	delCalls  []string
	delErr    error
	pipelines []*mockPipeline
	pipeIdx   int
}

func (m *mockRedisClient) Get(ctx context.Context, _ string) *redis.StringCmd {
	cmd := redis.NewStringCmd(ctx)
	if m.getErr != nil {
		cmd.SetErr(m.getErr)
	} else {
		cmd.SetVal(m.getResult)
	}
	return cmd
}

func (m *mockRedisClient) Set(ctx context.Context, _ string, _ interface{}, _ time.Duration) *redis.StatusCmd {
	cmd := redis.NewStatusCmd(ctx)
	if m.setErr != nil {
		cmd.SetErr(m.setErr)
	}
	return cmd
}

func (m *mockRedisClient) Del(ctx context.Context, keys ...string) *redis.IntCmd {
	m.delCalls = append(m.delCalls, keys...)
	cmd := redis.NewIntCmd(ctx)
	if m.delErr != nil {
		cmd.SetErr(m.delErr)
	}
	return cmd
}

func (m *mockRedisClient) Pipeline() redis.Pipeliner {
	if m.pipeIdx < len(m.pipelines) {
		p := m.pipelines[m.pipeIdx]
		m.pipeIdx++
		return p
	}
	return &mockPipeline{}
}

func keyEncoder(id int64) string {
	return "test:" + strconv.FormatInt(id, 10)
}

// helper to create a string pointer.
func strPtr(s string) *string { return &s }

// --- mock error pipeline (returns a specific error for all GET commands) ---

type mockErrorPipeline struct {
	redis.Pipeliner
	getErr   error
	setCalls []string
	execErr  error
}

func (p *mockErrorPipeline) Get(ctx context.Context, _ string) *redis.StringCmd {
	cmd := redis.NewStringCmd(ctx)
	cmd.SetErr(p.getErr)
	return cmd
}

func (p *mockErrorPipeline) Set(ctx context.Context, key string, _ interface{}, _ time.Duration) *redis.StatusCmd {
	p.setCalls = append(p.setCalls, key)
	return redis.NewStatusCmd(ctx)
}

func (p *mockErrorPipeline) Exec(_ context.Context) ([]redis.Cmder, error) {
	return nil, p.execErr
}

// --- mock redis client with error pipeline for first call ---

type mockRedisClientWithErrorPipeline struct {
	redis.UniversalClient
	errorPipeline *mockErrorPipeline
	writePipeline *mockPipeline
	delCalls      []string
	callCount     int
}

func (m *mockRedisClientWithErrorPipeline) Del(ctx context.Context, keys ...string) *redis.IntCmd {
	m.delCalls = append(m.delCalls, keys...)
	return redis.NewIntCmd(ctx)
}

func (m *mockRedisClientWithErrorPipeline) Pipeline() redis.Pipeliner {
	m.callCount++
	if m.callCount == 1 {
		return m.errorPipeline
	}
	return m.writePipeline
}

// ===========================
// Map() tests
// ===========================

func TestExtendedRedis_Map_EmptyKeys(t *testing.T) {
	c := NewExtendedRedis[int64, *testItem](
		&mockRedisClient{}, &mockExtGetter{}, keyEncoder, testCodec{}, time.Minute, nil,
	)
	m, err := c.Map(context.Background(), []int64{})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(m) != 0 {
		t.Fatalf("expected empty map, got %d entries", len(m))
	}
}

func TestExtendedRedis_Map_AllHits(t *testing.T) {
	readPipe := &mockPipeline{
		getResults: map[string]*string{
			"test:1": strPtr("1:hello"),
			"test:2": strPtr("2:world"),
		},
	}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe}}
	c := NewExtendedRedis[int64, *testItem](
		client, &mockExtGetter{}, keyEncoder, testCodec{}, time.Minute, nil,
	)
	m, err := c.Map(context.Background(), []int64{1, 2})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(m) != 2 {
		t.Fatalf("expected 2 entries, got %d", len(m))
	}
	if m[1].Value != "hello" {
		t.Errorf("expected hello, got %s", m[1].Value)
	}
	if m[2].Value != "world" {
		t.Errorf("expected world, got %s", m[2].Value)
	}
	hits, misses := c.Stats()
	if hits != 2 || misses != 0 {
		t.Errorf("expected 2 hits 0 misses, got %d/%d", hits, misses)
	}
}

func TestExtendedRedis_Map_AllMisses(t *testing.T) {
	readPipe := &mockPipeline{getResults: map[string]*string{}} // all misses
	writePipe := &mockPipeline{}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe, writePipe}}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, keys []int64) ([]*testItem, error) {
			var items []*testItem
			for _, k := range keys {
				items = append(items, &testItem{ID: k, Value: fmt.Sprintf("val%d", k)})
			}
			return items, nil
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute, nil,
	)
	m, err := c.Map(context.Background(), []int64{1, 2})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(m) != 2 {
		t.Fatalf("expected 2 entries, got %d", len(m))
	}
	if m[1].Value != "val1" {
		t.Errorf("expected val1, got %s", m[1].Value)
	}
	hits, misses := c.Stats()
	if hits != 0 || misses != 2 {
		t.Errorf("expected 0 hits 2 misses, got %d/%d", hits, misses)
	}
	if len(writePipe.setCalls) != 2 {
		t.Errorf("expected 2 SET calls, got %d", len(writePipe.setCalls))
	}
}

func TestExtendedRedis_Map_MixedHitsAndMisses(t *testing.T) {
	readPipe := &mockPipeline{
		getResults: map[string]*string{
			"test:1": strPtr("1:cached"),
			// key 2 missing
		},
	}
	writePipe := &mockPipeline{}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe, writePipe}}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, keys []int64) ([]*testItem, error) {
			var items []*testItem
			for _, k := range keys {
				items = append(items, &testItem{ID: k, Value: "fetched"})
			}
			return items, nil
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute, nil,
	)
	m, err := c.Map(context.Background(), []int64{1, 2})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(m) != 2 {
		t.Fatalf("expected 2 entries, got %d", len(m))
	}
	if m[1].Value != "cached" {
		t.Errorf("expected cached, got %s", m[1].Value)
	}
	if m[2].Value != "fetched" {
		t.Errorf("expected fetched, got %s", m[2].Value)
	}
	hits, misses := c.Stats()
	if hits != 1 || misses != 1 {
		t.Errorf("expected 1 hit 1 miss, got %d/%d", hits, misses)
	}
}

func TestExtendedRedis_Map_PipelineExecError_FallsThrough(t *testing.T) {
	var loggedErr error
	readPipe := &mockPipeline{
		getResults: map[string]*string{},
		execErr:    errors.New("redis down"),
	}
	writePipe := &mockPipeline{}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe, writePipe}}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, keys []int64) ([]*testItem, error) {
			var items []*testItem
			for _, k := range keys {
				items = append(items, &testItem{ID: k, Value: "fallback"})
			}
			return items, nil
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, err error) { loggedErr = err },
	)
	m, err := c.Map(context.Background(), []int64{1})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if m[1].Value != "fallback" {
		t.Errorf("expected fallback, got %s", m[1].Value)
	}
	if loggedErr == nil {
		t.Error("expected error to be logged")
	}
}

func TestExtendedRedis_Map_FindManyError(t *testing.T) {
	readPipe := &mockPipeline{getResults: map[string]*string{}}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe}}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, _ []int64) ([]*testItem, error) {
			return nil, errors.New("db error")
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute, nil,
	)
	_, err := c.Map(context.Background(), []int64{1})
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestExtendedRedis_Map_DecodeError_EvictsAndTreatedAsMiss(t *testing.T) {
	var loggedErrs []error
	readPipe := &mockPipeline{
		getResults: map[string]*string{
			"test:1": strPtr("invalid_no_colon"),
		},
	}
	writePipe := &mockPipeline{}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe, writePipe}}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, keys []int64) ([]*testItem, error) {
			var items []*testItem
			for _, k := range keys {
				items = append(items, &testItem{ID: k, Value: "refetched"})
			}
			return items, nil
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, err error) { loggedErrs = append(loggedErrs, err) },
	)
	m, err := c.Map(context.Background(), []int64{1})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if m[1].Value != "refetched" {
		t.Errorf("expected refetched, got %s", m[1].Value)
	}
	// Should have logged decode error
	if len(loggedErrs) == 0 {
		t.Error("expected decode error to be logged")
	}
	// Should have evicted the bad key
	if len(client.delCalls) != 1 || client.delCalls[0] != "test:1" {
		t.Errorf("expected DEL test:1, got %v", client.delCalls)
	}
}

func TestExtendedRedis_Map_Deduplicates(t *testing.T) {
	readPipe := &mockPipeline{
		getResults: map[string]*string{
			"test:1": strPtr("1:hello"),
		},
	}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe}}
	c := NewExtendedRedis[int64, *testItem](
		client, &mockExtGetter{}, keyEncoder, testCodec{}, time.Minute, nil,
	)
	m, err := c.Map(context.Background(), []int64{1, 1, 1})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(m) != 1 {
		t.Fatalf("expected 1 entry, got %d", len(m))
	}
	// Should have only done 1 GET
	if len(readPipe.getCalls) != 1 {
		t.Errorf("expected 1 GET call, got %d", len(readPipe.getCalls))
	}
}

func TestExtendedRedis_Map_WritePipelineError_Logged(t *testing.T) {
	var loggedErr error
	readPipe := &mockPipeline{getResults: map[string]*string{}}
	writePipe := &mockPipeline{execErr: errors.New("pipe error")}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe, writePipe}}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, keys []int64) ([]*testItem, error) {
			return []*testItem{{ID: 1, Value: "val"}}, nil
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, err error) { loggedErr = err },
	)
	m, err := c.Map(context.Background(), []int64{1})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if m[1].Value != "val" {
		t.Errorf("expected val, got %s", m[1].Value)
	}
	if loggedErr == nil {
		t.Error("expected pipeline error to be logged")
	}
}

func TestExtendedRedis_Map_RedisNilExecError_NotLogged(t *testing.T) {
	var logCalled bool
	readPipe := &mockPipeline{
		getResults: map[string]*string{},
		execErr:    redis.Nil,
	}
	writePipe := &mockPipeline{}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe, writePipe}}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, keys []int64) ([]*testItem, error) {
			return []*testItem{{ID: 1, Value: "val"}}, nil
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, _ error) { logCalled = true },
	)
	_, err := c.Map(context.Background(), []int64{1})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if logCalled {
		t.Error("redis.Nil exec error should not be logged")
	}
}

func TestExtendedRedis_Map_PerKeyNonNilError_TreatedAsMiss(t *testing.T) {
	var loggedErrs []error
	errPipe := &mockErrorPipeline{
		getErr:  errors.New("connection reset"),
		execErr: errors.New("connection reset"),
	}
	writePipe := &mockPipeline{}
	client := &mockRedisClientWithErrorPipeline{
		errorPipeline: errPipe,
		writePipeline: writePipe,
	}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, keys []int64) ([]*testItem, error) {
			return []*testItem{{ID: 1, Value: "from_db"}}, nil
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, err error) { loggedErrs = append(loggedErrs, err) },
	)
	m, err := c.Map(context.Background(), []int64{1})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if m[1].Value != "from_db" {
		t.Errorf("expected from_db, got %s", m[1].Value)
	}
	// Should have logged the pipeline exec error + per-key error
	if len(loggedErrs) < 1 {
		t.Error("expected errors to be logged")
	}
}

func TestExtendedRedis_Map_FindManyUnexpectedKey_Skipped(t *testing.T) {
	var loggedErrs []error
	readPipe := &mockPipeline{getResults: map[string]*string{}}
	writePipe := &mockPipeline{}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe, writePipe}}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, _ []int64) ([]*testItem, error) {
			return []*testItem{
				{ID: 1, Value: "ok"},
				{ID: 99, Value: "unexpected"}, // not in requested keys
			}, nil
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, err error) { loggedErrs = append(loggedErrs, err) },
	)
	m, err := c.Map(context.Background(), []int64{1})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if m[1].Value != "ok" {
		t.Errorf("expected ok, got %s", m[1].Value)
	}
	if _, exists := m[99]; exists {
		t.Error("unexpected key 99 should not be in result")
	}
	// Should have logged the unexpected key
	foundUnexpected := false
	for _, e := range loggedErrs {
		if e != nil && errors.Is(e, e) {
			foundUnexpected = true
		}
	}
	if !foundUnexpected || len(loggedErrs) == 0 {
		t.Error("expected unexpected key error to be logged")
	}
}

func TestExtendedRedis_Map_WritePipelineNilError_NotLogged(t *testing.T) {
	var logCalled bool
	readPipe := &mockPipeline{getResults: map[string]*string{}}
	writePipe := &mockPipeline{execErr: redis.Nil}
	client := &mockRedisClient{pipelines: []*mockPipeline{readPipe, writePipe}}
	getter := &mockExtGetter{
		findManyFn: func(_ context.Context, _ []int64) ([]*testItem, error) {
			return []*testItem{{ID: 1, Value: "val"}}, nil
		},
	}
	c := NewExtendedRedis[int64, *testItem](
		client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, _ error) { logCalled = true },
	)
	_, err := c.Map(context.Background(), []int64{1})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if logCalled {
		t.Error("redis.Nil from write pipeline should not be logged")
	}
}

// ===========================
// Get() tests
// ===========================

func TestRedis_Get_CacheHit(t *testing.T) {
	client := &mockRedisClient{getResult: "42:hello"}
	getter := &mockExtGetter{
		findFn: func(_ context.Context, _ int64) (*testItem, error) {
			t.Fatal("Find should not be called on cache hit")
			panic("unreachable")
		},
	}
	c := NewRedis[int64, *testItem](client, getter, keyEncoder, testCodec{}, time.Minute, nil)
	item, err := c.Get(context.Background(), 42)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if item.Value != "hello" {
		t.Errorf("expected hello, got %s", item.Value)
	}
	hits, misses := c.Stats()
	if hits != 1 || misses != 0 {
		t.Errorf("expected 1 hit 0 miss, got %d/%d", hits, misses)
	}
}

func TestRedis_Get_CacheMiss_FetchesFromSource(t *testing.T) {
	client := &mockRedisClient{getErr: redis.Nil}
	getter := &mockExtGetter{
		findFn: func(_ context.Context, key int64) (*testItem, error) {
			return &testItem{ID: key, Value: "from_db"}, nil
		},
	}
	c := NewRedis[int64, *testItem](client, getter, keyEncoder, testCodec{}, time.Minute, nil)
	item, err := c.Get(context.Background(), 1)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if item.Value != "from_db" {
		t.Errorf("expected from_db, got %s", item.Value)
	}
	hits, misses := c.Stats()
	if hits != 0 || misses != 1 {
		t.Errorf("expected 0 hit 1 miss, got %d/%d", hits, misses)
	}
}

func TestRedis_Get_RedisError_FallsToSource(t *testing.T) {
	var loggedErr error
	client := &mockRedisClient{getErr: errors.New("redis down")}
	getter := &mockExtGetter{
		findFn: func(_ context.Context, key int64) (*testItem, error) {
			return &testItem{ID: key, Value: "fallback"}, nil
		},
	}
	c := NewRedis[int64, *testItem](client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, err error) { loggedErr = err },
	)
	item, err := c.Get(context.Background(), 1)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if item.Value != "fallback" {
		t.Errorf("expected fallback, got %s", item.Value)
	}
	if loggedErr == nil {
		t.Error("expected redis error to be logged")
	}
}

func TestRedis_Get_DecodeError_EvictsAndRefetches(t *testing.T) {
	var loggedErrs []error
	client := &mockRedisClient{getResult: "invalid_no_colon"}
	getter := &mockExtGetter{
		findFn: func(_ context.Context, key int64) (*testItem, error) {
			return &testItem{ID: key, Value: "fresh"}, nil
		},
	}
	c := NewRedis[int64, *testItem](client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, err error) { loggedErrs = append(loggedErrs, err) },
	)
	item, err := c.Get(context.Background(), 1)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if item.Value != "fresh" {
		t.Errorf("expected fresh, got %s", item.Value)
	}
	if len(loggedErrs) == 0 {
		t.Error("expected decode error to be logged")
	}
	if len(client.delCalls) != 1 {
		t.Errorf("expected 1 DEL call for eviction, got %d", len(client.delCalls))
	}
}

func TestRedis_Get_FindError_ReturnsError(t *testing.T) {
	client := &mockRedisClient{getErr: redis.Nil}
	getter := &mockExtGetter{
		findFn: func(_ context.Context, _ int64) (*testItem, error) {
			return nil, errors.New("not found")
		},
	}
	c := NewRedis[int64, *testItem](client, getter, keyEncoder, testCodec{}, time.Minute, nil)
	_, err := c.Get(context.Background(), 1)
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestRedis_Get_SetError_LoggedButReturnsItem(t *testing.T) {
	var loggedErr error
	client := &mockRedisClient{
		getErr: redis.Nil,
		setErr: errors.New("set failed"),
	}
	getter := &mockExtGetter{
		findFn: func(_ context.Context, key int64) (*testItem, error) {
			return &testItem{ID: key, Value: "val"}, nil
		},
	}
	c := NewRedis[int64, *testItem](client, getter, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, err error) { loggedErr = err },
	)
	item, err := c.Get(context.Background(), 1)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if item.Value != "val" {
		t.Errorf("expected val, got %s", item.Value)
	}
	if loggedErr == nil {
		t.Error("expected SET error to be logged")
	}
}

// ===========================
// Evict() tests
// ===========================

func TestRedis_Evict_Success(t *testing.T) {
	client := &mockRedisClient{}
	c := NewRedis[int64, *testItem](client, &mockExtGetter{}, keyEncoder, testCodec{}, time.Minute, nil)
	c.Evict(context.Background(), 42)
	if len(client.delCalls) != 1 || client.delCalls[0] != "test:42" {
		t.Errorf("expected DEL test:42, got %v", client.delCalls)
	}
}

func TestRedis_Evict_Error_Logged(t *testing.T) {
	var loggedErr error
	client := &mockRedisClient{delErr: errors.New("del failed")}
	c := NewRedis[int64, *testItem](client, &mockExtGetter{}, keyEncoder, testCodec{}, time.Minute,
		func(_ context.Context, err error) { loggedErr = err },
	)
	c.Evict(context.Background(), 42)
	if loggedErr == nil {
		t.Error("expected DEL error to be logged")
	}
}

// ===========================
// Constructor / Stats tests
// ===========================

func TestNewExtendedRedis(t *testing.T) {
	getter := &mockExtGetter{}
	c := NewExtendedRedis[int64, *testItem](
		&mockRedisClient{}, getter, keyEncoder, testCodec{}, time.Minute, nil,
	)
	if c == nil {
		t.Fatal("expected non-nil cache")
	}
	hits, misses := c.Stats()
	if hits != 0 || misses != 0 {
		t.Errorf("expected 0/0, got %d/%d", hits, misses)
	}
}