tercul-backend/internal/platform/cache/redis_cache_test.go
google-labs-jules[bot] 53aa4d0344
Security Hardening and GraphQL Caching (#69)
* feat: add security middleware, graphql apq, and improved linting

- Add RateLimit, RequestValidation, and CORS middleware.
- Configure middleware chain in API server.
- Implement Redis cache for GraphQL Automatic Persisted Queries.
- Add .golangci.yml and fix linting issues (shadowing, timeouts).

* feat: security, caching and linting config

- Fix .golangci.yml config for govet shadow check
- (Previous changes: Security middleware, GraphQL APQ, Linting fixes)

* fix: resolve remaining lint errors

- Fix unhandled errors in tests (errcheck)
- Define constants for repeated strings (goconst)
- Suppress high complexity warnings with nolint:gocyclo
- Fix integer overflow warnings (gosec)
- Add package comments
- Split long lines (lll)
- Rename Analyse -> Analyze (misspell)
- Fix naked returns and unused params

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-12-01 00:14:22 +01:00

411 lines
12 KiB
Go

package cache_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
"time"
"tercul/internal/platform/cache"
"tercul/internal/platform/config"
"github.com/go-redis/redismock/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewRedisCache(t *testing.T) {
client, _ := redismock.NewClientMock()
keyGen := cache.NewDefaultKeyGenerator("test:")
defaultExpiry := 2 * time.Hour
c := cache.NewRedisCache(client, keyGen, defaultExpiry)
require.NotNil(t, c)
}
func TestNewDefaultRedisCache(t *testing.T) {
t.Run("success", func(t *testing.T) {
// This test requires a running Redis instance or a mock for the Ping command.
// For now, we'll just test that the function returns a non-nil cache
// when the config is valid. A more comprehensive test would involve
// mocking the redis.NewClient and its Ping method.
cfg := &config.Config{
RedisAddr: "localhost:6379",
}
// Since this function actually tries to connect, we can't fully test it in a unit test
// without a live redis. We will skip a full test here and focus on the other methods.
// In a real-world scenario, we might use a test container with Redis.
// For now, we'll just ensure it doesn't panic with a basic config.
// A proper integration test would be better suited for this.
_ = cfg
// _, err := cache.NewDefaultRedisCache(cfg)
// assert.NoError(t, err)
})
t.Run("connection error", func(t *testing.T) {
cfg := &config.Config{
RedisAddr: "localhost:9999", // Invalid address
}
_, err := cache.NewDefaultRedisCache(cfg)
assert.Error(t, err)
})
}
func TestRedisCache_Get(t *testing.T) {
client, mock := redismock.NewClientMock()
c := cache.NewRedisCache(client, nil, time.Hour)
ctx := context.Background()
key := "test-key"
expectedValue := map[string]string{"foo": "bar"}
expectedBytes, _ := json.Marshal(expectedValue)
t.Run("success", func(t *testing.T) {
mock.ExpectGet(key).SetVal(string(expectedBytes))
var result map[string]string
err := c.Get(ctx, key, &result)
assert.NoError(t, err)
assert.Equal(t, expectedValue, result)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("cache miss", func(t *testing.T) {
mock.ExpectGet(key).RedisNil()
var result map[string]string
err := c.Get(ctx, key, &result)
assert.Error(t, err)
assert.Equal(t, "cache miss", err.Error())
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("redis error", func(t *testing.T) {
mock.ExpectGet(key).SetErr(errors.New("redis error"))
var result map[string]string
err := c.Get(ctx, key, &result)
assert.Error(t, err)
assert.Equal(t, "redis error", err.Error())
assert.NoError(t, mock.ExpectationsWereMet())
})
}
func TestRedisCache_InvalidateEntityType(t *testing.T) {
ctx := context.Background()
entityType := "user"
pattern := "tercul:user:*"
t.Run("success with multiple batches", func(t *testing.T) {
client, mock := redismock.NewClientMock()
keyGen := cache.NewDefaultKeyGenerator("tercul:")
c := cache.NewRedisCache(client, keyGen, time.Hour)
keysBatch1 := make([]string, 100)
for i := 0; i < 100; i++ {
keysBatch1[i] = fmt.Sprintf("tercul:user:key%d", i)
}
keysBatch2 := []string{"tercul:user:key101", "tercul:user:key102"}
// Mocking the SCAN and DEL calls in the correct order
mock.ExpectScan(0, pattern, 100).SetVal(keysBatch1, 1)
mock.ExpectDel(keysBatch1...).SetVal(100)
mock.ExpectScan(1, pattern, 100).SetVal(keysBatch2, 0)
mock.ExpectDel(keysBatch2...).SetVal(2)
err := c.InvalidateEntityType(ctx, entityType)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("success with single batch", func(t *testing.T) {
client, mock := redismock.NewClientMock()
keyGen := cache.NewDefaultKeyGenerator("tercul:")
c := cache.NewRedisCache(client, keyGen, time.Hour)
keys := []string{"tercul:user:key1", "tercul:user:key2"}
mock.ExpectScan(0, pattern, 100).SetVal(keys, 0)
mock.ExpectDel(keys...).SetVal(2)
err := c.InvalidateEntityType(ctx, entityType)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("scan error", func(t *testing.T) {
client, mock := redismock.NewClientMock()
keyGen := cache.NewDefaultKeyGenerator("tercul:")
c := cache.NewRedisCache(client, keyGen, time.Hour)
mock.ExpectScan(0, pattern, 100).SetErr(errors.New("scan error"))
err := c.InvalidateEntityType(ctx, entityType)
assert.Error(t, err)
assert.Equal(t, "scan error", err.Error())
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("del error", func(t *testing.T) {
client, mock := redismock.NewClientMock()
keyGen := cache.NewDefaultKeyGenerator("tercul:")
c := cache.NewRedisCache(client, keyGen, time.Hour)
keys := []string{"tercul:user:key1"}
mock.ExpectScan(0, pattern, 100).SetVal(keys, 0)
mock.ExpectDel(keys...).SetErr(errors.New("del error"))
err := c.InvalidateEntityType(ctx, entityType)
assert.Error(t, err)
assert.Equal(t, "del error", err.Error())
assert.NoError(t, mock.ExpectationsWereMet())
})
}
func TestRedisCache_GetMulti(t *testing.T) {
client, mock := redismock.NewClientMock()
c := cache.NewRedisCache(client, nil, time.Hour)
ctx := context.Background()
keys := []string{"key1", "key2", "key3"}
expectedValues := []interface{}{"value1", nil, "value3"}
expectedResult := map[string][]byte{
"key1": []byte("value1"),
"key3": []byte("value3"),
}
t.Run("success", func(t *testing.T) {
mock.ExpectMGet(keys...).SetVal(expectedValues)
result, err := c.GetMulti(ctx, keys)
assert.NoError(t, err)
assert.Equal(t, expectedResult, result)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("no keys", func(t *testing.T) {
result, err := c.GetMulti(ctx, []string{})
assert.NoError(t, err)
assert.Empty(t, result)
})
t.Run("redis error", func(t *testing.T) {
mock.ExpectMGet(keys...).SetErr(errors.New("redis error"))
_, err := c.GetMulti(ctx, keys)
assert.Error(t, err)
assert.Equal(t, "redis error", err.Error())
assert.NoError(t, mock.ExpectationsWereMet())
})
}
func TestRedisCache_SetMulti(t *testing.T) {
ctx := context.Background()
items := map[string]interface{}{
"key1": "value1",
"key2": 123,
}
t.Run("success", func(t *testing.T) {
client, mock := redismock.NewClientMock()
c := cache.NewRedisCache(client, nil, time.Hour)
mock.MatchExpectationsInOrder(false)
// Expect each Set command within the pipeline
for key, value := range items {
data, _ := json.Marshal(value)
mock.ExpectSet(key, data, time.Hour).SetVal("OK")
}
// The SetMulti function will call Exec(), which triggers the mock's pipeline hook
err := c.SetMulti(ctx, items, 0)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("no items", func(t *testing.T) {
client, _ := redismock.NewClientMock()
c := cache.NewRedisCache(client, nil, time.Hour)
err := c.SetMulti(ctx, map[string]interface{}{}, 0)
assert.NoError(t, err)
})
t.Run("redis error", func(t *testing.T) {
client, mock := redismock.NewClientMock()
c := cache.NewRedisCache(client, nil, time.Hour)
mock.MatchExpectationsInOrder(false)
// To simulate a pipeline execution error, we make one of the inner commands fail.
// The hook will stop processing on the first error and return it.
data1, _ := json.Marshal("value1")
mock.ExpectSet("key1", data1, time.Hour).SetErr(errors.New("redis error"))
data2, _ := json.Marshal(123)
mock.ExpectSet("key2", data2, time.Hour).SetVal("OK")
err := c.SetMulti(ctx, items, 0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "redis error")
})
}
func TestRedisCache_EntityOperations(t *testing.T) {
client, mock := redismock.NewClientMock()
keyGen := cache.NewDefaultKeyGenerator("tercul:")
c := cache.NewRedisCache(client, keyGen, time.Hour)
ctx := context.Background()
entityType := "user"
id := uint(123)
entityKey := "tercul:user:id:123"
value := map[string]string{"name": "test"}
valueBytes, _ := json.Marshal(value)
page := 1
pageSize := 10
listKey := "tercul:user:list:1:10"
listValue := []string{"user1", "user2"}
listValueBytes, _ := json.Marshal(listValue)
t.Run("GetEntity", func(t *testing.T) {
mock.ExpectGet(entityKey).SetVal(string(valueBytes))
var result map[string]string
err := c.GetEntity(ctx, entityType, id, &result)
assert.NoError(t, err)
assert.Equal(t, value, result)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("SetEntity", func(t *testing.T) {
mock.ExpectSet(entityKey, valueBytes, time.Hour).SetVal("OK")
err := c.SetEntity(ctx, entityType, id, value, 0)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("DeleteEntity", func(t *testing.T) {
mock.ExpectDel(entityKey).SetVal(1)
err := c.DeleteEntity(ctx, entityType, id)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("GetList", func(t *testing.T) {
mock.ExpectGet(listKey).SetVal(string(listValueBytes))
var result []string
err := c.GetList(ctx, entityType, page, pageSize, &result)
assert.NoError(t, err)
assert.Equal(t, listValue, result)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("SetList", func(t *testing.T) {
mock.ExpectSet(listKey, listValueBytes, time.Hour).SetVal("OK")
err := c.SetList(ctx, entityType, page, pageSize, listValue, 0)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("DeleteList", func(t *testing.T) {
mock.ExpectDel(listKey).SetVal(1)
err := c.DeleteList(ctx, entityType, page, pageSize)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
}
func TestRedisCache_Set(t *testing.T) {
client, mock := redismock.NewClientMock()
c := cache.NewRedisCache(client, nil, time.Hour)
ctx := context.Background()
key := "test-key"
value := map[string]string{"foo": "bar"}
expectedBytes, _ := json.Marshal(value)
t.Run("success with default expiration", func(t *testing.T) {
mock.ExpectSet(key, expectedBytes, time.Hour).SetVal("OK")
err := c.Set(ctx, key, value, 0)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("success with custom expiration", func(t *testing.T) {
expiration := 5 * time.Minute
mock.ExpectSet(key, expectedBytes, expiration).SetVal("OK")
err := c.Set(ctx, key, value, expiration)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("redis error", func(t *testing.T) {
mock.ExpectSet(key, expectedBytes, time.Hour).SetErr(errors.New("redis error"))
err := c.Set(ctx, key, value, 0)
assert.Error(t, err)
assert.Equal(t, "redis error", err.Error())
assert.NoError(t, mock.ExpectationsWereMet())
})
}
func TestRedisCache_Delete(t *testing.T) {
client, mock := redismock.NewClientMock()
c := cache.NewRedisCache(client, nil, time.Hour)
ctx := context.Background()
key := "test-key"
t.Run("success", func(t *testing.T) {
mock.ExpectDel(key).SetVal(1)
err := c.Delete(ctx, key)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("redis error", func(t *testing.T) {
mock.ExpectDel(key).SetErr(errors.New("redis error"))
err := c.Delete(ctx, key)
assert.Error(t, err)
assert.Equal(t, "redis error", err.Error())
assert.NoError(t, mock.ExpectationsWereMet())
})
}
func TestRedisCache_Clear(t *testing.T) {
client, mock := redismock.NewClientMock()
c := cache.NewRedisCache(client, nil, time.Hour)
ctx := context.Background()
t.Run("success", func(t *testing.T) {
mock.ExpectFlushAll().SetVal("OK")
err := c.Clear(ctx)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
})
t.Run("redis error", func(t *testing.T) {
mock.ExpectFlushAll().SetErr(errors.New("redis error"))
err := c.Clear(ctx)
assert.Error(t, err)
assert.Equal(t, "redis error", err.Error())
assert.NoError(t, mock.ExpectationsWereMet())
})
}