mirror of
https://github.com/status-im/eth-rpc-proxy.git
synced 2026-08-27 17:51:11 +00:00
* feat(go-proxy-cache): handle when cache is not accessible * feat(go-proxy-cache): serve metrics over http * feat(go-proxy-cache): metrics * feat(go-proxy-cache): fix pr comments fixes #67 * feat(go-proxy-cache): update github actions * feat(go-proxy-cache): fix linter
525 lines
14 KiB
Go
525 lines
14 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"
|
|
|
|
"go-proxy-cache/internal/cache"
|
|
"go-proxy-cache/internal/cache/service"
|
|
"go-proxy-cache/internal/interfaces/mock"
|
|
"go-proxy-cache/internal/models"
|
|
"go-proxy-cache/internal/utils"
|
|
)
|
|
|
|
// 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 *models.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()
|
|
|
|
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)
|
|
|
|
// Create cache service with mocked dependencies
|
|
cacheService := service.NewCacheService(l1Cache, l2Cache, cacheClassifier, false, logger)
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|