Files
Andrey Bocharnikov bc49abe0ce fix(cache): extract proxy-common/cache package (#109)
fixes #106
chore: restore auth readme
chore: restore auth readme
2026-02-11 15:57:11 +04:00

696 lines
20 KiB
Go

package httpserver
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go.uber.org/mock/gomock"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
"github.com/status-im/proxy-common/models"
"go-proxy-cache/internal/cache"
"go-proxy-cache/internal/cache/service"
"go-proxy-cache/internal/interfaces/mock"
localModels "go-proxy-cache/internal/models"
"go-proxy-cache/internal/utils"
cachemock "github.com/status-im/proxy-common/cache/mock"
)
// Note: Metrics are now package-level variables in the metrics package
// mockCache implements the Cache interface for testing
type mockCache struct {
data map[string]*models.CacheEntry
hits map[string]bool
}
func newMockCache() *mockCache {
return &mockCache{
data: make(map[string]*models.CacheEntry),
hits: make(map[string]bool),
}
}
func (m *mockCache) Get(key string) (*models.CacheEntry, bool) {
entry, found := m.data[key]
if found {
m.hits[key] = true
}
return entry, found
}
func (m *mockCache) GetStale(key string) (*models.CacheEntry, bool) {
entry, found := m.data[key]
return entry, found
}
func (m *mockCache) Set(key string, val []byte, ttl models.TTL) {
now := time.Now().Unix()
m.data[key] = &models.CacheEntry{
Data: val,
CreatedAt: now,
StaleAt: now + int64(ttl.Fresh.Seconds()),
ExpiresAt: now + int64(ttl.Fresh.Seconds()) + int64(ttl.Stale.Seconds()),
}
}
func (m *mockCache) Delete(key string) {
delete(m.data, key)
}
// setupMockCacheClassifier configures the mock cache classifier with common expectations
func setupMockCacheClassifier(ctrl *gomock.Controller) *mock.MockCacheRulesClassifier {
mockClassifier := mock.NewMockCacheRulesClassifier(ctrl)
// Set up common expectations
mockClassifier.EXPECT().GetTtl(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(chain, network string, request *localModels.JSONRPCRequest) models.CacheInfo {
switch request.Method {
case "eth_getBlockByHash":
return models.CacheInfo{TTL: 86400 * time.Second, CacheType: "permanent"}
case "eth_blockNumber":
return models.CacheInfo{TTL: 5 * time.Second, CacheType: "short"}
default:
return models.CacheInfo{TTL: 0, CacheType: "none"}
}
},
).AnyTimes()
// Set up ShouldSkipNullCache expectation - return true for methods that should skip null caching
mockClassifier.EXPECT().ShouldSkipNullCache(gomock.Any()).DoAndReturn(
func(method string) bool {
switch method {
case "eth_getTransactionReceipt", "eth_getTransactionByHash", "eth_getBlockByHash", "eth_getBlockByNumber":
return true
default:
return false
}
},
).AnyTimes()
return mockClassifier
}
// setupCacheService creates a cache service with mock caches for testing
func setupCacheService(ctrl *gomock.Controller, logger *zap.Logger) (*service.CacheService, *mockCache, *mockCache) {
l1Cache := newMockCache()
l2Cache := newMockCache()
cacheClassifier := setupMockCacheClassifier(ctrl)
mockMetrics := cachemock.NewMockMetricsRecorder(ctrl)
mockMetrics.EXPECT().TimeCacheOperation(gomock.Any(), gomock.Any()).Return(func() {}).AnyTimes()
mockMetrics.EXPECT().RecordCacheMiss(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockMetrics.EXPECT().RecordCacheHit(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockMetrics.EXPECT().RecordCacheSet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockMetrics.EXPECT().RecordCacheBytesRead(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
// Create cache service with mocked dependencies
cacheService := service.NewCacheService(l1Cache, l2Cache, cacheClassifier, false, logger, mockMetrics)
return cacheService, l1Cache, l2Cache
}
// generateCacheKey generates the actual cache key using the real key builder
func generateCacheKey(chain, network, rawBody string) (string, error) {
keyBuilder := cache.NewKeyBuilder()
request, err := utils.ParseJSONRPCRequest(rawBody)
if err != nil {
return "", err
}
return keyBuilder.Build(chain, network, request)
}
func TestServer_HandleGet(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
logger := zaptest.NewLogger(t)
cacheService, l1Cache, l2Cache := setupCacheService(ctrl, logger)
server := NewServer(cacheService, logger)
tests := []struct {
name string
requestBody CacheRequest
setupCache func()
expectedStatus int
expectedFound bool
}{
{
name: "cache miss",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_blockNumber","params":[],"jsonrpc":"2.0","id":1}`,
},
expectedStatus: http.StatusOK,
expectedFound: false,
},
{
name: "cache hit L1",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_blockNumber","params":[],"jsonrpc":"2.0","id":1}`,
},
setupCache: func() {
key, _ := generateCacheKey("ethereum", "mainnet", `{"method":"eth_blockNumber","params":[],"jsonrpc":"2.0","id":1}`)
l1Cache.Set(key, []byte(`{"result":"0x123","id":1}`), models.TTL{Fresh: time.Hour, Stale: time.Minute})
},
expectedStatus: http.StatusOK,
expectedFound: true,
},
{
name: "cache hit L2",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_getBlockByHash","params":["0x123",true],"jsonrpc":"2.0","id":2}`,
},
setupCache: func() {
key, _ := generateCacheKey("ethereum", "mainnet", `{"method":"eth_getBlockByHash","params":["0x123",true],"jsonrpc":"2.0","id":2}`)
l2Cache.Set(key, []byte(`{"result":{"number":"0x123"},"id":2}`), models.TTL{Fresh: time.Hour, Stale: time.Minute})
},
expectedStatus: http.StatusOK,
expectedFound: true,
},
{
name: "invalid request - missing chain",
requestBody: CacheRequest{
Network: "mainnet",
RawBody: `{"method":"eth_blockNumber"}`,
},
expectedStatus: http.StatusBadRequest,
},
{
name: "invalid request - empty raw body",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: "",
},
expectedStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset caches
l1Cache.data = make(map[string]*models.CacheEntry)
l2Cache.data = make(map[string]*models.CacheEntry)
if tt.setupCache != nil {
tt.setupCache()
}
body, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest("POST", "/cache/get", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
server.handleGet(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("handleGet() status = %v, want %v", w.Code, tt.expectedStatus)
}
if tt.expectedStatus == http.StatusOK {
var response CacheResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if response.Found != tt.expectedFound {
t.Errorf("handleGet() Found = %v, want %v", response.Found, tt.expectedFound)
}
if !response.Success {
t.Errorf("handleGet() Success = false, want true")
}
}
})
}
}
func TestServer_HandleGet_CacheStatus(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
logger := zaptest.NewLogger(t)
cacheService, l1Cache, l2Cache := setupCacheService(ctrl, logger)
server := NewServer(cacheService, logger)
tests := []struct {
name string
requestBody CacheRequest
setupCache func()
expectedStatus int
expectedCacheStatus models.CacheStatus
}{
{
name: "cache status MISS",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_blockNumber","params":[],"jsonrpc":"2.0","id":1}`,
},
expectedStatus: http.StatusOK,
expectedCacheStatus: models.CacheStatusMiss,
},
{
name: "cache status HIT",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_blockNumber","params":[],"jsonrpc":"2.0","id":1}`,
},
setupCache: func() {
key, _ := generateCacheKey("ethereum", "mainnet", `{"method":"eth_blockNumber","params":[],"jsonrpc":"2.0","id":1}`)
l1Cache.Set(key, []byte(`{"result":"0x123","id":1}`), models.TTL{Fresh: time.Hour, Stale: time.Minute})
},
expectedStatus: http.StatusOK,
expectedCacheStatus: models.CacheStatusHit,
},
{
name: "cache status BYPASS",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"unknown_method","params":[],"jsonrpc":"2.0","id":1}`,
},
expectedStatus: http.StatusOK,
expectedCacheStatus: models.CacheStatusBypass,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset caches
l1Cache.data = make(map[string]*models.CacheEntry)
l2Cache.data = make(map[string]*models.CacheEntry)
if tt.setupCache != nil {
tt.setupCache()
}
body, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest("POST", "/cache/get", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
server.handleGet(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("handleGet() status = %v, want %v", w.Code, tt.expectedStatus)
}
if tt.expectedStatus == http.StatusOK {
var response CacheResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if response.CacheStatus != tt.expectedCacheStatus {
t.Errorf("handleGet() CacheStatus = %v, want %v", response.CacheStatus, tt.expectedCacheStatus)
}
if !response.Success {
t.Errorf("handleGet() Success = false, want true")
}
}
})
}
}
func TestServer_HandleSet(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
logger := zaptest.NewLogger(t)
cacheService, l1Cache, l2Cache := setupCacheService(ctrl, logger)
server := NewServer(cacheService, logger)
tests := []struct {
name string
requestBody CacheRequest
expectedStatus int
checkCache bool
}{
{
name: "successful set",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_getBlockByHash","params":["0x123",true],"jsonrpc":"2.0","id":1}`,
Data: `{"result":{"number":"0x123"},"id":1}`,
},
expectedStatus: http.StatusOK,
checkCache: true,
},
{
name: "set with custom TTL",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_blockNumber","params":[],"jsonrpc":"2.0","id":2}`,
Data: `{"result":"0x456","id":2}`,
TTL: intPtr(3600),
StaleTTL: intPtr(360),
},
expectedStatus: http.StatusOK,
checkCache: true,
},
{
name: "invalid request - missing data",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_blockNumber"}`,
},
expectedStatus: http.StatusBadRequest,
},
{
name: "zero TTL method",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"unknown_method"}`,
Data: `{"result":"test"}`,
},
expectedStatus: http.StatusOK,
checkCache: false, // Should not be cached due to zero TTL
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset caches
l1Cache.data = make(map[string]*models.CacheEntry)
l2Cache.data = make(map[string]*models.CacheEntry)
body, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest("POST", "/cache/set", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
server.handleSet(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("handleSet() status = %v, want %v", w.Code, tt.expectedStatus)
}
if tt.expectedStatus == http.StatusOK {
var response CacheResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if !response.Success {
t.Errorf("handleSet() Success = false, want true")
}
// Check if data was actually cached
if tt.checkCache {
key, err := generateCacheKey(tt.requestBody.Chain, tt.requestBody.Network, tt.requestBody.RawBody)
if err != nil {
t.Fatalf("Failed to generate cache key: %v", err)
}
if _, found := l1Cache.data[key]; !found {
t.Errorf("handleSet() data not found in L1 cache")
}
if _, found := l2Cache.data[key]; !found {
t.Errorf("handleSet() data not found in L2 cache")
}
}
}
})
}
}
// TestServer_HandleSet_SkipNullCache verifies that null results for skip-listed methods
// are not written to L1 or L2 cache
func TestServer_HandleSet_SkipNullCache(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
logger := zaptest.NewLogger(t)
cacheService, l1Cache, l2Cache := setupCacheService(ctrl, logger)
server := NewServer(cacheService, logger)
tests := []struct {
name string
requestBody CacheRequest
expectedStatus int
expectCached bool
description string
}{
{
name: "skip-listed method with null result should not cache",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_getTransactionReceipt","params":["0x123"],"jsonrpc":"2.0","id":1}`,
Data: `{"jsonrpc":"2.0","id":1,"result":null}`,
},
expectedStatus: http.StatusOK,
expectCached: false,
description: "eth_getTransactionReceipt with null result should skip caching",
},
{
name: "skip-listed method with non-null result should cache",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_getBlockByHash","params":["0x456",true],"jsonrpc":"2.0","id":2}`,
Data: `{"jsonrpc":"2.0","id":2,"result":{"number":"0x123","hash":"0x456"}}`,
},
expectedStatus: http.StatusOK,
expectCached: true,
description: "eth_getBlockByHash with non-null result should be cached",
},
{
name: "non-skip-listed method with null result should cache",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_getBalance","params":["0x789","latest"],"jsonrpc":"2.0","id":3}`,
Data: `{"jsonrpc":"2.0","id":3,"result":null}`,
},
expectedStatus: http.StatusOK,
expectCached: false, // eth_getBalance has TTL 0 in mock, so won't cache anyway
description: "non-skip-listed method returns based on TTL rules",
},
{
name: "eth_getBlockByHash with null result should not cache",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_getBlockByHash","params":["0xnonexistent",true],"jsonrpc":"2.0","id":4}`,
Data: `{"jsonrpc":"2.0","id":4,"result":null}`,
},
expectedStatus: http.StatusOK,
expectCached: false,
description: "eth_getBlockByHash with null result should skip caching",
},
{
name: "eth_getBlockByNumber with null result should not cache",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_getBlockByNumber","params":["0x999999999",true],"jsonrpc":"2.0","id":5}`,
Data: `{"jsonrpc":"2.0","id":5,"result":null}`,
},
expectedStatus: http.StatusOK,
expectCached: false,
description: "eth_getBlockByNumber with null result should skip caching",
},
{
name: "eth_getTransactionByHash with null result should not cache",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_getTransactionByHash","params":["0xpending"],"jsonrpc":"2.0","id":6}`,
Data: `{"jsonrpc":"2.0","id":6,"result":null}`,
},
expectedStatus: http.StatusOK,
expectCached: false,
description: "eth_getTransactionByHash with null result should skip caching",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset caches before each test
l1Cache.data = make(map[string]*models.CacheEntry)
l2Cache.data = make(map[string]*models.CacheEntry)
body, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest("POST", "/cache/set", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
server.handleSet(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("%s: status = %v, want %v", tt.description, w.Code, tt.expectedStatus)
}
if tt.expectedStatus == http.StatusOK {
var response CacheResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if !response.Success {
t.Errorf("%s: Success = false, want true", tt.description)
}
// Generate cache key to check if data was cached
key, err := generateCacheKey(tt.requestBody.Chain, tt.requestBody.Network, tt.requestBody.RawBody)
if err != nil {
t.Fatalf("Failed to generate cache key: %v", err)
}
_, foundInL1 := l1Cache.data[key]
_, foundInL2 := l2Cache.data[key]
if tt.expectCached {
if !foundInL1 {
t.Errorf("%s: expected data in L1 cache, but not found", tt.description)
}
if !foundInL2 {
t.Errorf("%s: expected data in L2 cache, but not found", tt.description)
}
} else {
if foundInL1 {
t.Errorf("%s: expected L1 cache to be empty, but found data", tt.description)
}
if foundInL2 {
t.Errorf("%s: expected L2 cache to be empty, but found data", tt.description)
}
}
}
})
}
}
func TestServer_HandleCacheInfo(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
logger := zaptest.NewLogger(t)
cacheService, _, _ := setupCacheService(ctrl, logger)
server := NewServer(cacheService, logger)
tests := []struct {
name string
requestBody CacheRequest
expectedStatus int
expectedTTL int
}{
{
name: "permanent method",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_getBlockByHash","params":["0x123",true],"jsonrpc":"2.0","id":1}`,
},
expectedStatus: http.StatusOK,
expectedTTL: 86400,
},
{
name: "short method",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: `{"method":"eth_blockNumber","params":[],"jsonrpc":"2.0","id":2}`,
},
expectedStatus: http.StatusOK,
expectedTTL: 5,
},
{
name: "invalid request",
requestBody: CacheRequest{
Chain: "ethereum",
Network: "mainnet",
RawBody: "",
},
expectedStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest("POST", "/cache/info", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
server.handleCacheInfo(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("handleCacheInfo() status = %v, want %v", w.Code, tt.expectedStatus)
}
if tt.expectedStatus == http.StatusOK {
var response CacheResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if !response.Success {
t.Errorf("handleCacheInfo() Success = false, want true")
}
if response.TTL != tt.expectedTTL {
t.Errorf("handleCacheInfo() TTL = %v, want %v", response.TTL, tt.expectedTTL)
}
}
})
}
}
func TestServer_HandleHealth(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
logger := zaptest.NewLogger(t)
cacheService, _, _ := setupCacheService(ctrl, logger)
server := NewServer(cacheService, logger)
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
server.handleHealth(w, req)
if w.Code != http.StatusOK {
t.Errorf("handleHealth() status = %v, want %v", w.Code, http.StatusOK)
}
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to unmarshal health response: %v", err)
}
if status, ok := response["status"]; !ok || status != "healthy" {
t.Errorf("handleHealth() status = %v, want 'healthy'", status)
}
}
// Helper function to create int pointer
func intPtr(i int) *int {
return &i
}