go-api/cache_test.go
Snider 0ab962a258 feat: add WithCache response caching middleware
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:41:48 +00:00

252 lines
6.8 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/go-api"
)
// cacheCounterGroup registers routes that increment a counter on each call,
// allowing tests to distinguish cached from uncached responses.
type cacheCounterGroup struct {
counter atomic.Int64
}
func (g *cacheCounterGroup) Name() string { return "cache-test" }
func (g *cacheCounterGroup) BasePath() string { return "/cache" }
func (g *cacheCounterGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/counter", func(c *gin.Context) {
n := g.counter.Add(1)
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("call-%d", n)))
})
rg.GET("/other", func(c *gin.Context) {
n := g.counter.Add(1)
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("other-%d", n)))
})
rg.POST("/counter", func(c *gin.Context) {
n := g.counter.Add(1)
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("post-%d", n)))
})
}
// ── WithCache ───────────────────────────────────────────────────────────
func TestWithCache_Good_CachesGETResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(5 * time.Second))
e.Register(grp)
h := e.Handler()
// First request — cache MISS.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w1.Code)
}
body1 := w1.Body.String()
if !strings.Contains(body1, "call-1") {
t.Fatalf("expected body to contain %q, got %q", "call-1", body1)
}
// Second request — should be a cache HIT returning the same body.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w2.Code)
}
body2 := w2.Body.String()
if body1 != body2 {
t.Fatalf("expected cached body %q, got %q", body1, body2)
}
cacheHeader := w2.Header().Get("X-Cache")
if cacheHeader != "HIT" {
t.Fatalf("expected X-Cache=HIT, got %q", cacheHeader)
}
// Counter should still be 1 (handler was not called again).
if grp.counter.Load() != 1 {
t.Fatalf("expected counter=1 (cached), got %d", grp.counter.Load())
}
}
func TestWithCache_Good_POSTNotCached(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(5 * time.Second))
e.Register(grp)
h := e.Handler()
// First POST request.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodPost, "/cache/counter", nil)
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w1.Code)
}
var resp1 api.Response[string]
if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp1.Data != "post-1" {
t.Fatalf("expected Data=%q, got %q", "post-1", resp1.Data)
}
// Second POST request — should NOT be cached, counter increments.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodPost, "/cache/counter", nil)
h.ServeHTTP(w2, req2)
var resp2 api.Response[string]
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp2.Data != "post-2" {
t.Fatalf("expected Data=%q, got %q", "post-2", resp2.Data)
}
// Counter should be 2 — both POST requests hit the handler.
if grp.counter.Load() != 2 {
t.Fatalf("expected counter=2, got %d", grp.counter.Load())
}
}
func TestWithCache_Good_DifferentPathsSeparatelyCached(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(5 * time.Second))
e.Register(grp)
h := e.Handler()
// Request to /cache/counter.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w1, req1)
body1 := w1.Body.String()
if !strings.Contains(body1, "call-1") {
t.Fatalf("expected body to contain %q, got %q", "call-1", body1)
}
// Request to /cache/other — different path, should miss cache.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/cache/other", nil)
h.ServeHTTP(w2, req2)
body2 := w2.Body.String()
if !strings.Contains(body2, "other-2") {
t.Fatalf("expected body to contain %q, got %q", "other-2", body2)
}
// Counter is 2 — both paths hit the handler.
if grp.counter.Load() != 2 {
t.Fatalf("expected counter=2, got %d", grp.counter.Load())
}
// Re-request /cache/counter — should serve cached "call-1".
w3 := httptest.NewRecorder()
req3, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w3, req3)
body3 := w3.Body.String()
if body1 != body3 {
t.Fatalf("expected cached body %q, got %q", body1, body3)
}
// Counter unchanged — served from cache.
if grp.counter.Load() != 2 {
t.Fatalf("expected counter=2 (cached), got %d", grp.counter.Load())
}
}
func TestWithCache_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(
api.WithRequestID(),
api.WithCache(5*time.Second),
)
e.Register(grp)
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// RequestID middleware should still set X-Request-ID.
rid := w.Header().Get("X-Request-ID")
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
// Body should contain the expected response.
body := w.Body.String()
if !strings.Contains(body, "call-1") {
t.Fatalf("expected body to contain %q, got %q", "call-1", body)
}
}
func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(50 * time.Millisecond))
e.Register(grp)
h := e.Handler()
// First request — populates cache.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w1, req1)
body1 := w1.Body.String()
if !strings.Contains(body1, "call-1") {
t.Fatalf("expected body to contain %q, got %q", "call-1", body1)
}
// Wait for cache to expire.
time.Sleep(100 * time.Millisecond)
// Second request — cache expired, handler called again.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w2, req2)
body2 := w2.Body.String()
if !strings.Contains(body2, "call-2") {
t.Fatalf("expected body to contain %q after expiry, got %q", "call-2", body2)
}
// Counter should be 2 — both requests hit the handler.
if grp.counter.Load() != 2 {
t.Fatalf("expected counter=2, got %d", grp.counter.Load())
}
}