main pico / pkg / httpcache / cache_test.go
Antonio Mika  ·  2026-10-09
   1package httpcache
   2
   3import (
   4	"log/slog"
   5	"net/http"
   6	"net/http/httptest"
   7	"strconv"
   8	"strings"
   9	"testing"
  10	"time"
  11)
  12
  13/*
  14TODO:
  15	- Request no-store store-prevention (RFC 9111 §5.2.1.5): verify a no-store request does not populate/update cache for subsequent requests.
  16	- Authorization storage/use constraints (RFC 9111 §3.5): authenticated responses should not be reused unless explicitly permitted by response directives.
  17	- Vary: * behavior (RFC 9111 §4.1): ensure such responses are not reused for subsequent requests.
  18	- Multi-field Vary matching (RFC 9111 §4.1): all nominated request fields must match original request values, not just one.
  19	- Age correction with upstream metadata (RFC 9111 §4.2.3, §5.1): test interactions of stored response Date/Age values rather than only local clock delta.
  20*/
  21
  22// TestContext holds shared test state.
  23type TestContext struct {
  24	t            *testing.T
  25	handler      http.Handler
  26	cachedServer *httptest.Server
  27}
  28
  29// NewTestContext creates a test context with a backend and cached server.
  30func NewTestContext(t *testing.T, cacheHandler http.Handler) *TestContext {
  31	tc := &TestContext{
  32		t:       t,
  33		handler: cacheHandler,
  34	}
  35
  36	tc.cachedServer = httptest.NewServer(tc.handler)
  37	t.Cleanup(tc.cachedServer.Close)
  38
  39	return tc
  40}
  41
  42func (tc *TestContext) Do(req *http.Request) (*http.Response, error) {
  43	return http.DefaultClient.Do(req)
  44}
  45
  46func (tc *TestContext) DoWithHeaders(req *http.Request, headers map[string][]string) (*http.Response, error) {
  47	reqCopy := req.Clone(req.Context())
  48	reqCopy.Header = req.Header.Clone()
  49	if reqCopy.Header == nil {
  50		reqCopy.Header = make(http.Header)
  51	}
  52
  53	for key, val := range headers {
  54		reqCopy.Header.Del(key)
  55		for _, v := range val {
  56			reqCopy.Header.Add(key, v)
  57		}
  58	}
  59	return http.DefaultClient.Do(reqCopy)
  60}
  61
  62func testCacheValue(afterCreated time.Duration) *CacheValue {
  63	return &CacheValue{
  64		Header:    map[string][]string{},
  65		Body:      []byte("success"),
  66		CreatedAt: time.Now().Add(-afterCreated),
  67	}
  68}
  69
  70// RFC 9211 The Cache-Status HTTP Response Header Field
  71// https://www.rfc-editor.org/rfc/rfc9211#section-2
  72func TestCacheCacheStatus(t *testing.T) {
  73	mux := http.NewServeMux()
  74	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  75		w.WriteHeader(200)
  76		_, _ = w.Write([]byte("success"))
  77	})
  78
  79	logger := slog.Default()
  80	handler := NewHttpCache(logger, mux)
  81	tc := NewTestContext(t, handler)
  82	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
  83
  84	// first request hits backend
  85	resp1, _ := tc.Do(req)
  86	if resp1.StatusCode != http.StatusOK {
  87		t.Errorf("expected 200, got %d", resp1.StatusCode)
  88	}
  89	status := resp1.Header.Get("cache-status")
  90	if !strings.Contains(status, "miss") {
  91		t.Errorf("expected miss, got %s", status)
  92	}
  93
  94	// second request hits cache
  95	resp2, _ := tc.Do(req)
  96	if resp2.StatusCode != http.StatusOK {
  97		t.Errorf("expected 200, got %d", resp2.StatusCode)
  98	}
  99	status = resp2.Header.Get("cache-status")
 100	if !strings.Contains(status, "hit") {
 101		t.Errorf("expected hit, got %s", status)
 102	}
 103}
 104
 105// RFC 9110 15.1-2 Heuristically Cacheable
 106// https://www.rfc-editor.org/rfc/rfc9110#section-15.1-2
 107// 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501.
 108func TestCacheStatusCode(t *testing.T) {
 109	mux := http.NewServeMux()
 110	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 111		w.WriteHeader(500)
 112		_, _ = w.Write([]byte("boom!"))
 113	})
 114
 115	logger := slog.Default()
 116	handler := NewHttpCache(logger, mux)
 117	tc := NewTestContext(t, handler)
 118	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 119
 120	// first request hits backend
 121	resp1, _ := tc.Do(req)
 122	if resp1.StatusCode != http.StatusInternalServerError {
 123		t.Errorf("expected 500, got %d", resp1.StatusCode)
 124	}
 125	status := resp1.Header.Get("cache-status")
 126	if !strings.Contains(status, "miss") {
 127		t.Errorf("expected miss, got %s", status)
 128	}
 129
 130	// second request hits backend
 131	resp2, _ := tc.Do(req)
 132	if resp2.StatusCode != http.StatusInternalServerError {
 133		t.Errorf("expected 500, got %d", resp2.StatusCode)
 134	}
 135	status = resp2.Header.Get("cache-status")
 136	if !strings.Contains(status, "miss") {
 137		t.Errorf("expected miss, got %s", status)
 138	}
 139}
 140
 141// RFC 9111 2.3 Opinion - Only store GET requests
 142// https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3
 143func TestCacheMethod(t *testing.T) {
 144	mux := http.NewServeMux()
 145	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 146		w.WriteHeader(200)
 147		_, _ = w.Write([]byte("success"))
 148	})
 149
 150	logger := slog.Default()
 151	handler := NewHttpCache(logger, mux)
 152	tc := NewTestContext(t, handler)
 153	req, _ := http.NewRequest("POST", tc.cachedServer.URL+"/test", nil)
 154
 155	// first request hits backend
 156	resp1, _ := tc.Do(req)
 157	if resp1.StatusCode != http.StatusOK {
 158		t.Errorf("expected 200, got %d", resp1.StatusCode)
 159	}
 160	status := resp1.Header.Get("cache-status")
 161	if !strings.Contains(status, "miss") {
 162		t.Errorf("expected miss, got %s", status)
 163	}
 164
 165	// second request hits backend
 166	resp2, _ := tc.Do(req)
 167	if resp2.StatusCode != http.StatusOK {
 168		t.Errorf("expected 200, got %d", resp2.StatusCode)
 169	}
 170	status = resp2.Header.Get("cache-status")
 171	if !strings.Contains(status, "miss") {
 172		t.Errorf("expected miss, got %s", status)
 173	}
 174}
 175
 176// RFC 9111 3.1 Storing Header and Trailer Fields
 177// https://www.rfc-editor.org/rfc/rfc9111.html#section-3.1
 178func TestCacheStoringHeaders(t *testing.T) {
 179	mux := http.NewServeMux()
 180	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 181		w.Header().Set("connection", "idk")
 182		w.Header().Set("proxy-authenticate", "idk")
 183		w.Header().Set("proxy-authentication-info", "idk")
 184		w.Header().Set("proxy-authorization", "idk")
 185
 186		w.WriteHeader(200)
 187		_, _ = w.Write([]byte("success"))
 188	})
 189
 190	logger := slog.Default()
 191	handler := NewHttpCache(logger, mux)
 192	tc := NewTestContext(t, handler)
 193	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 194
 195	// first request hits backend
 196	resp1, _ := tc.Do(req)
 197	if resp1.StatusCode != http.StatusOK {
 198		t.Errorf("expected 200, got %d", resp1.StatusCode)
 199	}
 200	status := resp1.Header.Get("cache-status")
 201	if !strings.Contains(status, "miss") {
 202		t.Errorf("expected miss, got %s", status)
 203	}
 204
 205	// second request hits cache
 206	resp2, _ := tc.Do(req)
 207	if resp2.StatusCode != http.StatusOK {
 208		t.Errorf("expected 200, got %d", resp2.StatusCode)
 209	}
 210	headers := []string{
 211		resp2.Header.Get("connection"),
 212		resp2.Header.Get("proxy-authenticate"),
 213		resp2.Header.Get("proxy-authentication-info"),
 214		resp2.Header.Get("proxy-authorization"),
 215	}
 216	for _, hdr := range headers {
 217		if hdr != "" {
 218			t.Errorf("expected no header, found one: %s", hdr)
 219		}
 220	}
 221}
 222
 223// RFC 9111 4.1 Vary.
 224// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1
 225func TestCacheVary(t *testing.T) {
 226	mux := http.NewServeMux()
 227	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 228		w.WriteHeader(200)
 229		_, _ = w.Write([]byte("success"))
 230	})
 231
 232	logger := slog.Default()
 233	handler := NewHttpCache(logger, mux)
 234	tc := NewTestContext(t, handler)
 235
 236	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 237	cacheKey := handler.GetCacheKey(req)
 238	cv := testCacheValue(250 * time.Second)
 239	cv.Header["Vary"] = []string{"Accept-Encoding"}
 240	// VaryRequestHeaders snapshots the request header values that were present
 241	// when this entry was cached, keyed by the lowercase header name.
 242	cv.VaryRequestHeaders = map[string]string{"accept-encoding": "gzip"}
 243	cacheValue := cv.encode()
 244	handler.Cache.Add(cacheKey, cacheValue)
 245
 246	respMatch, _ := tc.DoWithHeaders(req, map[string][]string{
 247		"Accept-Encoding": {"gzip"},
 248	})
 249	status := respMatch.Header.Get("cache-status")
 250	if !strings.Contains(status, "hit") {
 251		t.Errorf("expected hit, got %s", status)
 252	}
 253
 254	respMisMatch, _ := tc.DoWithHeaders(req, map[string][]string{
 255		"Accept-Encoding": {"text/plain"},
 256	})
 257	status = respMisMatch.Header.Get("cache-status")
 258	if !strings.Contains(status, "miss") {
 259		t.Errorf("expected miss, got %s", status)
 260	}
 261}
 262
 263// RFC 9111 4.3 Validation.
 264// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3
 265// Last-Modified ETag If-Match If-None-Match If-Range If-Modified-Since If-Unmodified-Since
 266// RFC 9110 13 Conditional Requests.
 267// https://www.rfc-editor.org/rfc/rfc9110#section-13
 268func TestCacheValidation(t *testing.T) {
 269	actual := time.Now().Add(-10 * time.Minute).UTC()
 270	actualStr := actual.Format(time.RFC1123)
 271	now := time.Now().UTC()
 272	nowStr := now.Format(time.RFC1123)
 273	early := time.Now().Add(-20 * time.Minute)
 274	earlyStr := early.Format(time.RFC1123)
 275	tests := []struct {
 276		name             string
 277		link             string
 278		validationHeader string
 279		validationValue  string
 280		extraHeaders     map[string][]string
 281		expected         string
 282		originStatus     int
 283		expectedStatus   int
 284	}{
 285		{
 286			name:             "RFC 9110 13.1.2 If-None-Match",
 287			link:             "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2",
 288			validationHeader: "If-None-Match",
 289			validationValue:  "\"abc\"",
 290			expected:         "hit",
 291			originStatus:     http.StatusOK,
 292			expectedStatus:   http.StatusOK,
 293		},
 294		{
 295			name:             "RFC 9110 13.1.2 If-None-Match Wildcard",
 296			link:             "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2",
 297			validationHeader: "If-None-Match",
 298			validationValue:  "*",
 299			expected:         "hit",
 300			originStatus:     http.StatusOK,
 301			expectedStatus:   http.StatusNotModified,
 302		},
 303		{
 304			name:             "RFC 9110 13.1.3 If-Modified-Since",
 305			link:             "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3",
 306			validationHeader: "If-Modified-Since",
 307			validationValue:  nowStr,
 308			expected:         "hit",
 309			originStatus:     http.StatusOK,
 310			expectedStatus:   http.StatusNotModified,
 311		},
 312		{
 313			name:             "RFC 9110 13.1.4 If-Unmodified-Since",
 314			link:             "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.4",
 315			validationHeader: "If-Unmodified-Since",
 316			validationValue:  earlyStr,
 317			expected:         "hit",
 318			originStatus:     http.StatusOK,
 319			expectedStatus:   http.StatusOK,
 320		},
 321		{
 322			name:             "RFC 9110 13.1.5 If-Range Date",
 323			link:             "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5",
 324			validationHeader: "If-Range",
 325			validationValue:  nowStr,
 326			extraHeaders: map[string][]string{
 327				"Range": {"bytes=0-3"},
 328			},
 329			expected:       "hit",
 330			originStatus:   http.StatusOK,
 331			expectedStatus: http.StatusOK,
 332		},
 333		{
 334			name:             "RFC 9110 13.1.5 If-Range Date Hit",
 335			link:             "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5",
 336			validationHeader: "If-Range",
 337			validationValue:  actualStr,
 338			extraHeaders: map[string][]string{
 339				"Range": {"bytes=0-3"},
 340			},
 341			expected:       "hit",
 342			originStatus:   http.StatusOK,
 343			expectedStatus: http.StatusOK,
 344		},
 345		{
 346			name:             "RFC 9110 13.1.5 If-Range ETag",
 347			link:             "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5",
 348			validationHeader: "If-Range",
 349			validationValue:  "\"abc\"",
 350			extraHeaders: map[string][]string{
 351				"Range": {"bytes=0-3"},
 352			},
 353			expected:       "hit",
 354			originStatus:   http.StatusOK,
 355			expectedStatus: http.StatusOK,
 356		},
 357		{
 358			name:             "RFC 9110 13.1.5 If-Range ETag Hit",
 359			link:             "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5",
 360			validationHeader: "If-Range",
 361			validationValue:  "\"ccc\"",
 362			extraHeaders: map[string][]string{
 363				"Range": {"bytes=0-3"},
 364			},
 365			expected:       "hit",
 366			originStatus:   http.StatusOK,
 367			expectedStatus: http.StatusOK,
 368		},
 369	}
 370
 371	for _, tt := range tests {
 372		mux := http.NewServeMux()
 373		mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 374			w.WriteHeader(tt.originStatus)
 375		})
 376
 377		logger := slog.Default()
 378		handler := NewHttpCache(logger, mux)
 379		handler.Ttl = time.Minute * 10
 380		tc := NewTestContext(t, handler)
 381
 382		req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 383		cacheKey := handler.GetCacheKey(req)
 384		cv := testCacheValue(250 * time.Second)
 385		cv.Header["ETag"] = []string{"ccc"}
 386		cv.Header["Last-Modified"] = []string{actualStr}
 387		cacheValue := cv.encode()
 388		handler.Cache.Add(cacheKey, cacheValue)
 389
 390		t.Run(tt.name, func(t *testing.T) {
 391			reqHeaders := map[string][]string{tt.validationHeader: {tt.validationValue}}
 392			for key, values := range tt.extraHeaders {
 393				reqHeaders[key] = values
 394			}
 395
 396			resp, _ := tc.DoWithHeaders(req, reqHeaders)
 397			actual := resp.Header.Get("cache-status")
 398			if !strings.Contains(actual, tt.expected) {
 399				t.Errorf("expected %s, got %s\n%s", tt.expected, actual, tt.link)
 400			}
 401			if resp.StatusCode != tt.expectedStatus {
 402				t.Errorf("expected status %d, got %d", tt.expectedStatus, resp.StatusCode)
 403			}
 404		})
 405	}
 406}
 407
 408// RFC 9111 5.1 Age.
 409// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.1
 410// RFC 9111 4.2.3 Calculating Age.
 411// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.3
 412func TestCacheAge(t *testing.T) {
 413	mux := http.NewServeMux()
 414	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 415		w.WriteHeader(200)
 416		_, _ = w.Write([]byte("success"))
 417	})
 418
 419	logger := slog.Default()
 420	handler := NewHttpCache(logger, mux)
 421	tc := NewTestContext(t, handler)
 422
 423	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 424	cacheKey := handler.GetCacheKey(req)
 425	cacheValue := testCacheValue(250 * time.Second).encode()
 426	handler.Cache.Add(cacheKey, cacheValue)
 427
 428	resp, _ := tc.Do(req)
 429	if resp.StatusCode != http.StatusOK {
 430		t.Errorf("expected 200, got %d", resp.StatusCode)
 431	}
 432	age := resp.Header.Get("age")
 433	ageNum, err := strconv.Atoi(age)
 434	if err != nil {
 435		t.Fatalf("invalide age header %s", err)
 436	}
 437	if ageNum != 251 {
 438		t.Errorf("expected 250, got %d", ageNum)
 439	}
 440}
 441
 442// RFC 9111 5.2.1 Request Directives
 443// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1
 444func TestCacheRequestDirectives(t *testing.T) {
 445	tests := []struct {
 446		name         string
 447		link         string
 448		cacheControl string
 449		expected     string
 450	}{
 451		{
 452			name:         "RFC 9111 5.2.1.1 Request Cache-Control: max-age",
 453			link:         "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1",
 454			cacheControl: "max-age=100",
 455			expected:     "miss",
 456		},
 457		{
 458			name:         "RFC 9111 5.2.1.2 Request Cache-Control: max-stale",
 459			link:         "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.2",
 460			cacheControl: "max-stale=300", // 300 max-stale + 250 age = 550 > 450 freshness
 461			expected:     "miss",
 462		},
 463		{
 464			name:         "RFC 9111 5.2.1.3 Request Cache-Control: min-fresh",
 465			link:         "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3",
 466			cacheControl: "min-fresh=400", // 600 ttl - 250 age = 350 freshness
 467			expected:     "miss",
 468		},
 469		{
 470			name:         "RFC 9111 5.2.1.4 Request Cache-Control: no-cache",
 471			link:         "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.4",
 472			cacheControl: "no-cache",
 473			expected:     "miss",
 474		},
 475		{
 476			name:         "RFC 9111 5.2.1.5 Request Cache-Control: no-store",
 477			link:         "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.5",
 478			cacheControl: "no-store",
 479			// you can reply with the cached version, just cannot store or update the response in
 480			//   the cache.
 481			expected: "hit",
 482		},
 483		{
 484			name:         "RFC 9111 5.2.1.6 Request Cache-Control: no-transform",
 485			link:         "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.6",
 486			cacheControl: "no-transform",
 487			expected:     "miss",
 488		},
 489		{
 490			name:         "RFC 9111 5.2.1.7 Request Cache-Control: only-if-cached",
 491			link:         "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.7",
 492			cacheControl: "only-if-cached",
 493			expected:     "hit",
 494		},
 495	}
 496
 497	for _, tt := range tests {
 498		mux := http.NewServeMux()
 499		mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 500			w.WriteHeader(200)
 501			_, _ = w.Write([]byte("success"))
 502		})
 503
 504		logger := slog.Default()
 505		handler := NewHttpCache(logger, mux)
 506		handler.Ttl = time.Minute * 10
 507		tc := NewTestContext(t, handler)
 508
 509		req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 510		cacheKey := handler.GetCacheKey(req)
 511		cacheValue := testCacheValue(250 * time.Second).encode()
 512		handler.Cache.Add(cacheKey, cacheValue)
 513
 514		t.Run(tt.name, func(t *testing.T) {
 515			resp, _ := tc.DoWithHeaders(req, map[string][]string{"Cache-Control": {tt.cacheControl}})
 516			actual := resp.Header.Get("cache-status")
 517			if !strings.Contains(actual, tt.expected) {
 518				t.Errorf("expected %s, got %s\n%s", tt.expected, actual, tt.link)
 519			}
 520		})
 521	}
 522}
 523
 524// RFC 9111 5.2.2 Response Directives
 525// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2
 526// These tests simply confirm that the response generated from origin server corresponds to the
 527// correct cache-control, http status code, and is "revalidated" the correct number of times.
 528// It does **not** validate the correct cache control logic like cache using max-age from origin
 529// server.
 530func TestCacheResponseDirectivesHasCacheControl(t *testing.T) {
 531	tests := []struct {
 532		name                      string
 533		link                      string
 534		cacheControl              string
 535		expectedOriginCalls       int
 536		expectedSecondCacheStatus string
 537	}{
 538		{
 539			name:                      "RFC 9111 5.2.2.1 Response Cache-Control max-age",
 540			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.1",
 541			cacheControl:              "max-age=100",
 542			expectedOriginCalls:       1,
 543			expectedSecondCacheStatus: "hit",
 544		},
 545		{
 546			name:                      "RFC 9111 5.2.2.2 Response Cache-Control must-revalidate",
 547			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.2",
 548			cacheControl:              "must-revalidate",
 549			expectedOriginCalls:       1,
 550			expectedSecondCacheStatus: "hit",
 551		},
 552		{
 553			name:                      "RFC 9111 5.2.2.3 Response Cache-Control must-understand",
 554			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.3",
 555			cacheControl:              "must-understand",
 556			expectedOriginCalls:       1,
 557			expectedSecondCacheStatus: "hit",
 558		},
 559		{
 560			name:                      "RFC 9111 5.2.2.4 Response Cache-Control no-cache",
 561			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4",
 562			cacheControl:              "no-cache",
 563			expectedOriginCalls:       2,
 564			expectedSecondCacheStatus: "miss",
 565		},
 566		{
 567			name:                      "RFC 9111 5.2.2.5 Response Cache-Control no-store",
 568			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.5",
 569			cacheControl:              "no-store",
 570			expectedOriginCalls:       2,
 571			expectedSecondCacheStatus: "miss",
 572		},
 573		{
 574			name:                      "RFC 9111 5.2.2.6 Response Cache-Control no-transform",
 575			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.6",
 576			cacheControl:              "no-transform",
 577			expectedOriginCalls:       1,
 578			expectedSecondCacheStatus: "hit",
 579		},
 580		{
 581			name:                      "RFC 9111 5.2.2.7 Response Cache-Control private",
 582			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.7",
 583			cacheControl:              "private",
 584			expectedOriginCalls:       2,
 585			expectedSecondCacheStatus: "miss", // this is a shared cache, do not store private
 586		},
 587		{
 588			name:                      "RFC 9111 5.2.2.8 Response Cache-Control proxy-revalidate",
 589			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.8",
 590			cacheControl:              "proxy-revalidate",
 591			expectedOriginCalls:       1,
 592			expectedSecondCacheStatus: "hit",
 593		},
 594		{
 595			name:                      "RFC 9111 5.2.2.9 Response Cache-Control public",
 596			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.9",
 597			cacheControl:              "public",
 598			expectedOriginCalls:       1,
 599			expectedSecondCacheStatus: "hit",
 600		},
 601		{
 602			name:                      "RFC 9111 5.2.2.10 Response Cache-Control s-maxage",
 603			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10",
 604			cacheControl:              "s-maxage=100",
 605			expectedOriginCalls:       1,
 606			expectedSecondCacheStatus: "hit",
 607		},
 608		{
 609			name:                      "RFC 9111 5.2.2.10 Response Cache-Control public+private",
 610			link:                      "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10",
 611			cacheControl:              "public, s-maxage=100, private",
 612			expectedOriginCalls:       2,
 613			expectedSecondCacheStatus: "miss", // be restrictive and adhere to private directive
 614		},
 615	}
 616
 617	for _, tt := range tests {
 618		t.Run(tt.name, func(t *testing.T) {
 619			mux := http.NewServeMux()
 620			actualOriginCalls := 0
 621			mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 622				actualOriginCalls += 1
 623				w.Header().Set("cache-control", tt.cacheControl)
 624				w.WriteHeader(200)
 625				_, _ = w.Write([]byte("success"))
 626			})
 627
 628			logger := slog.Default()
 629			handler := NewHttpCache(logger, mux)
 630			handler.Ttl = time.Minute * 10
 631			tc := NewTestContext(t, handler)
 632
 633			req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 634
 635			// first request hits backend
 636			resp1, _ := tc.Do(req)
 637			status := resp1.Header.Get("cache-status")
 638			if !strings.Contains(status, "miss") {
 639				t.Errorf("expected miss, got %s", status)
 640			}
 641
 642			// second request can be served from cache or forwarded depending on directive
 643			resp2, _ := tc.Do(req)
 644
 645			actualCc := resp2.Header.Get("cache-control")
 646			if actualCc != tt.cacheControl {
 647				t.Errorf("expected cache-control %s, got %s", tt.cacheControl, actualCc)
 648			}
 649			status = resp2.Header.Get("cache-status")
 650			if tt.expectedSecondCacheStatus != "" && !strings.Contains(status, tt.expectedSecondCacheStatus) {
 651				t.Errorf("expected %s, got %s", tt.expectedSecondCacheStatus, status)
 652			}
 653			if tt.expectedOriginCalls != actualOriginCalls {
 654				t.Errorf("expected %d origin calls, got %d", tt.expectedOriginCalls, actualOriginCalls)
 655			}
 656		})
 657	}
 658}
 659
 660// RFC 9111 5.3 Expires
 661// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
 662func TestCacheExpires(t *testing.T) {
 663	tests := []struct {
 664		name                string
 665		link                string
 666		expires             string
 667		expectedStatus      int
 668		expectedCacheStatus string
 669	}{
 670		{
 671			name:                "RFC 9111 5.3 Expires - future date",
 672			link:                "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3",
 673			expires:             time.Now().Add(10 * time.Minute).UTC().Format(http.TimeFormat),
 674			expectedStatus:      http.StatusOK,
 675			expectedCacheStatus: "hit",
 676		},
 677		{
 678			name:                "RFC 9111 5.3 Expires - expired response",
 679			link:                "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3",
 680			expires:             time.Now().Add(-10 * time.Minute).UTC().Format(http.TimeFormat),
 681			expectedStatus:      http.StatusOK,
 682			expectedCacheStatus: "fwd=uri-miss",
 683		},
 684		{
 685			name:                "RFC 9111 5.3 Expires - invalid Expires header",
 686			link:                "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3",
 687			expires:             "not-a-valid-date",
 688			expectedStatus:      http.StatusOK,
 689			expectedCacheStatus: "fwd=uri-miss",
 690		},
 691	}
 692
 693	for _, tt := range tests {
 694		t.Run(tt.name, func(t *testing.T) {
 695			mux := http.NewServeMux()
 696			mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 697				w.Header().Set("Expires", tt.expires)
 698				w.WriteHeader(200)
 699				_, _ = w.Write([]byte("success"))
 700			})
 701
 702			logger := slog.Default()
 703			handler := NewHttpCache(logger, mux)
 704			tc := NewTestContext(t, handler)
 705
 706			req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 707
 708			// first request hits backend
 709			resp1, _ := tc.Do(req)
 710			if resp1.StatusCode != http.StatusOK {
 711				t.Errorf("expected 200, got %d", resp1.StatusCode)
 712			}
 713
 714			// second request behavior depends on Expires header
 715			resp2, _ := tc.Do(req)
 716			status := resp2.Header.Get("cache-status")
 717			if !strings.Contains(status, tt.expectedCacheStatus) {
 718				t.Errorf("expected %s, got %s\n%s", tt.expectedCacheStatus, status, tt.link)
 719			}
 720		})
 721	}
 722}
 723
 724// RFC 9111 4.3.4 304 Not Modified
 725// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4
 726// When a cached entry is validated and the origin responds with 304, the cache:
 727// - Updates header metadata from the 304 response
 728// - Retains the cached body for subsequent requests.
 729// If the client's conditional headers no longer match the updated cache entry,
 730// the cache serves 200 with the full cached body.
 731func TestCache304NotModifiedMerge(t *testing.T) {
 732	originCalls := 0
 733
 734	// Validation handler: returns 304 when ETag matches, 200 otherwise.
 735	// The 304 response includes an updated ETag header (set before WriteHeader).
 736	validationMux := http.NewServeMux()
 737	validationMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 738		originCalls++
 739		if r.Header.Get("If-None-Match") == "\"abc\"" {
 740			// Set headers before WriteHeader (correct HTTP practice).
 741			w.Header().Set("etag", "\"abc-updated\"")
 742			w.WriteHeader(http.StatusNotModified)
 743			return
 744		}
 745		w.Header().Set("etag", "\"abc\"")
 746		w.Header().Set("cache-control", "max-age=60")
 747		w.WriteHeader(200)
 748		_, _ = w.Write([]byte("original body"))
 749	})
 750
 751	logger := slog.Default()
 752	handler := NewHttpCache(logger, validationMux)
 753	tc := NewTestContext(t, handler)
 754
 755	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 756
 757	// Manually populate cache with a stale entry that has must-revalidate
 758	// so validation is triggered on stale entries rather than the entry being deleted.
 759	cacheKey := handler.GetCacheKey(req)
 760	staleCv := testCacheValue(250 * time.Second)
 761	staleCv.Header["ETag"] = []string{"\"abc\""}
 762	staleCv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
 763	staleCv.Body = []byte("original body")
 764	cacheData := staleCv.encode()
 765	handler.Cache.Add(cacheKey, cacheData)
 766
 767	// First request with If-None-Match triggers validation; origin returns 304
 768	// with an updated ETag "abc-updated".
 769	// The cache merges the new ETag into the stored entry, then re-evaluates
 770	// the client's If-None-Match "abc" against the updated ETag "abc-updated".
 771	// They no longer match, so the cache serves the full cached body as 200.
 772	resp1, _ := tc.DoWithHeaders(req, map[string][]string{
 773		"If-None-Match": {"\"abc\""},
 774	})
 775	if resp1.StatusCode != http.StatusOK {
 776		t.Errorf("expected 200 (updated ETag no longer matches client If-None-Match), got %d", resp1.StatusCode)
 777	}
 778	status := resp1.Header.Get("cache-status")
 779	if !strings.Contains(status, "hit") {
 780		t.Errorf("expected cache-status to contain 'hit', got %s", status)
 781	}
 782
 783	// Second request without conditional headers should still serve the cached body.
 784	resp2, _ := tc.Do(req)
 785	if resp2.StatusCode != http.StatusOK {
 786		t.Errorf("expected 200, got %d", resp2.StatusCode)
 787	}
 788	bodyBuf := make([]byte, 1024)
 789	n, _ := resp2.Body.Read(bodyBuf)
 790	bodyStr := string(bodyBuf[:n])
 791	if bodyStr != "original body" {
 792		t.Errorf("expected cached body 'original body', got %q", bodyStr)
 793	}
 794	status2 := resp2.Header.Get("cache-status")
 795	if !strings.Contains(status2, "hit") {
 796		t.Errorf("expected cache-status hit on second request, got %s", status2)
 797	}
 798
 799	// Origin should have been called exactly once (304 validation only).
 800	if originCalls != 1 {
 801		t.Errorf("expected 1 origin call, got %d", originCalls)
 802	}
 803}
 804
 805func TestCacheUpstreamResponseBody(t *testing.T) {
 806	expectedBody := strings.Repeat("hello world! ", 1000)
 807	mux := http.NewServeMux()
 808	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 809		w.Header().Set("content-length", strconv.Itoa(len(expectedBody)))
 810		w.WriteHeader(200)
 811		_, _ = w.Write([]byte(expectedBody))
 812	})
 813
 814	logger := slog.Default()
 815	handler := NewHttpCache(logger, mux)
 816	tc := NewTestContext(t, handler)
 817	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 818
 819	// first request goes to upstream
 820	resp1, _ := tc.Do(req)
 821	if resp1.StatusCode != http.StatusOK {
 822		t.Fatalf("expected 200, got %d", resp1.StatusCode)
 823	}
 824	body1, _ := readBody(resp1)
 825	if body1 != expectedBody {
 826		t.Errorf("upstream body mismatch: got %d bytes, want %d bytes", len(body1), len(expectedBody))
 827	}
 828
 829	// second request served from cache
 830	resp2, _ := tc.Do(req)
 831	if resp2.StatusCode != http.StatusOK {
 832		t.Fatalf("expected 200, got %d", resp2.StatusCode)
 833	}
 834	body2, _ := readBody(resp2)
 835	if body2 != expectedBody {
 836		t.Errorf("cached body mismatch: got %d bytes, want %d bytes", len(body2), len(expectedBody))
 837	}
 838}
 839
 840func TestCacheUpstreamStatusCode(t *testing.T) {
 841	mux := http.NewServeMux()
 842	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 843		w.WriteHeader(201)
 844		_, _ = w.Write([]byte("created"))
 845	})
 846
 847	logger := slog.Default()
 848	handler := NewHttpCache(logger, mux)
 849	tc := NewTestContext(t, handler)
 850	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 851
 852	resp, _ := tc.Do(req)
 853	if resp.StatusCode != 201 {
 854		t.Errorf("expected 201, got %d", resp.StatusCode)
 855	}
 856	body, _ := readBody(resp)
 857	if body != "created" {
 858		t.Errorf("expected body 'created', got %q", body)
 859	}
 860}
 861
 862// RFC 9110 15.4.5: 304 responses MUST NOT contain a body.
 863// Even if the upstream handler writes body bytes with a 304,
 864// the cache layer must strip them before sending to the client.
 865func TestCache304NoBody(t *testing.T) {
 866	mux := http.NewServeMux()
 867	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 868		if r.Header.Get("If-None-Match") == "\"abc\"" {
 869			w.WriteHeader(http.StatusNotModified)
 870			// Misbehaving upstream writes body alongside 304
 871			_, _ = w.Write([]byte("should not appear"))
 872			return
 873		}
 874		w.Header().Set("etag", "\"abc\"")
 875		w.Header().Set("cache-control", "max-age=60, must-revalidate")
 876		w.WriteHeader(200)
 877		_, _ = w.Write([]byte("original body"))
 878	})
 879
 880	logger := slog.Default()
 881	handler := NewHttpCache(logger, mux)
 882	tc := NewTestContext(t, handler)
 883
 884	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 885
 886	// Populate cache with a stale must-revalidate entry so revalidation is triggered
 887	cacheKey := handler.GetCacheKey(req)
 888	cv := testCacheValue(250 * time.Second)
 889	cv.Header["ETag"] = []string{"\"abc\""}
 890	cv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
 891	cv.Body = []byte("original body")
 892	cacheData := cv.encode()
 893	handler.Cache.Add(cacheKey, cacheData)
 894
 895	// Trigger revalidation — upstream returns 304 with a spurious body.
 896	// Client request is unconditional, so cache serves the stored body as 200.
 897	resp, _ := tc.Do(req)
 898	if resp.StatusCode != http.StatusOK {
 899		t.Fatalf("expected 200, got %d", resp.StatusCode)
 900	}
 901	body, _ := readBody(resp)
 902	if body != "original body" {
 903		t.Errorf("expected cached body 'original body', got %q", body)
 904	}
 905}
 906
 907func readBody(resp *http.Response) (string, error) {
 908	defer resp.Body.Close() //nolint:errcheck
 909	buf := make([]byte, 0, 64*1024)
 910	tmp := make([]byte, 4096)
 911	for {
 912		n, err := resp.Body.Read(tmp)
 913		buf = append(buf, tmp[:n]...)
 914		if err != nil {
 915			break
 916		}
 917	}
 918	return string(buf), nil
 919}
 920
 921// Regression: a 304 from cache validation must include the cached response
 922// headers (ETag, Content-Type, Cache-Control, etc.) so the browser can match
 923// the 304 to its local cached body. Without them browsers show a blank page.
 924func TestCache304IncludesCachedHeaders(t *testing.T) {
 925	logger := slog.Default()
 926	mux := http.NewServeMux()
 927	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 928		w.Header().Set("etag", "\"abc\"")
 929		w.Header().Set("content-type", "text/html; charset=utf-8")
 930		w.Header().Set("cache-control", "max-age=300")
 931		w.WriteHeader(200)
 932		_, _ = w.Write([]byte("<h1>hello</h1>"))
 933	})
 934
 935	handler := NewHttpCache(logger, mux)
 936	tc := NewTestContext(t, handler)
 937	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 938
 939	// Populate cache with a fresh entry that has ETag, Content-Type, Cache-Control
 940	cacheKey := handler.GetCacheKey(req)
 941	cv := testCacheValue(10 * time.Second)
 942	cv.Header["ETag"] = []string{"\"abc\""}
 943	cv.Header["Content-Type"] = []string{"text/html; charset=utf-8"}
 944	cv.Header["Cache-Control"] = []string{"max-age=300"}
 945	cv.Body = []byte("<h1>hello</h1>")
 946	cacheData := cv.encode()
 947	handler.Cache.Add(cacheKey, cacheData)
 948
 949	// Send conditional request that triggers a 304 from the cache layer
 950	resp, _ := tc.DoWithHeaders(req, map[string][]string{
 951		"If-None-Match": {"\"abc\""},
 952	})
 953	if resp.StatusCode != http.StatusNotModified {
 954		t.Fatalf("expected 304, got %d", resp.StatusCode)
 955	}
 956
 957	// The 304 must carry the cached headers so the browser can use them
 958	// Note: Go's HTTP server strips Content-Type on 304 responses, which is fine
 959	// per RFC 9110 — the browser already has it from the original 200.
 960	if got := resp.Header.Get("ETag"); got != "\"abc\"" {
 961		t.Errorf("expected ETag %q, got %q", "\"abc\"", got)
 962	}
 963	if got := resp.Header.Get("Cache-Control"); got != "max-age=300" {
 964		t.Errorf("expected Cache-Control %q, got %q", "max-age=300", got)
 965	}
 966
 967	// Body must be empty per RFC 9110 15.4.5
 968	body, _ := readBody(resp)
 969	if body != "" {
 970		t.Errorf("expected empty body for 304, got %q", body)
 971	}
 972}
 973
 974func TestCacheAgeTtl(t *testing.T) {
 975	mux := http.NewServeMux()
 976	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 977		w.Header().Set("cache-control", "max-age=60")
 978		w.WriteHeader(200)
 979		_, _ = w.Write([]byte("success"))
 980	})
 981
 982	logger := slog.Default()
 983	handler := NewHttpCache(logger, mux)
 984	tc := NewTestContext(t, handler)
 985
 986	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 987
 988	// first request hits backend
 989	resp1, _ := tc.Do(req)
 990	if resp1.StatusCode != http.StatusOK {
 991		t.Errorf("expected 200, got %d", resp1.StatusCode)
 992	}
 993
 994	resp2, _ := tc.Do(req)
 995	status := resp2.Header.Get("cache-status")
 996	if !strings.Contains(status, "ttl=59;") {
 997		t.Errorf("expected ttl=59, got %s\n", status)
 998	}
 999}
1000
1001// RFC 9111 4.2.4 Stale Serving - must-revalidate requires revalidation.
1002// RFC 9111 4.3.1/4.3.2 Validation - cache MUST send stored validators
1003// when generating conditional upstream requests for stale entries.
1004func TestCacheMustRevalidateRevalidationHeaders(t *testing.T) {
1005	actual := time.Now().Add(-10 * time.Minute).UTC()
1006	actualStr := actual.Format(time.RFC1123)
1007
1008	tests := []struct {
1009		name                string
1010		cachedETag          string
1011		cachedLastModified  string
1012		expectedIfNoneMatch string
1013		expectedIfModified  string
1014	}{
1015		{
1016			name:                "RFC 9111 4.3.1 If-None-Match from stored ETag",
1017			cachedETag:          "\"abc\"",
1018			cachedLastModified:  "",
1019			expectedIfNoneMatch: "\"abc\"",
1020			expectedIfModified:  "",
1021		},
1022		{
1023			name:                "RFC 9111 4.3.2 If-Modified-Since from stored Last-Modified",
1024			cachedETag:          "",
1025			cachedLastModified:  actualStr,
1026			expectedIfNoneMatch: "",
1027			expectedIfModified:  actualStr,
1028		},
1029		{
1030			name:                "RFC 9111 4.3.1+4.3.2 Both validators present",
1031			cachedETag:          "\"xyz\"",
1032			cachedLastModified:  actualStr,
1033			expectedIfNoneMatch: "\"xyz\"",
1034			expectedIfModified:  actualStr,
1035		},
1036	}
1037
1038	for _, tt := range tests {
1039		t.Run(tt.name, func(t *testing.T) {
1040			var receivedIfNoneMatch, receivedIfModifiedSince string
1041			var receivedRequest *http.Request
1042
1043			mux := http.NewServeMux()
1044			mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
1045				receivedIfNoneMatch = r.Header.Get("If-None-Match")
1046				receivedIfModifiedSince = r.Header.Get("If-Modified-Since")
1047				receivedRequest = r
1048
1049				if r.Header.Get("If-None-Match") == "\"abc\"" ||
1050					r.Header.Get("If-None-Match") == "\"xyz\"" ||
1051					r.Header.Get("If-Modified-Since") != "" {
1052					w.WriteHeader(http.StatusNotModified)
1053					return
1054				}
1055				w.Header().Set("etag", "\"abc\"")
1056				w.Header().Set("cache-control", "max-age=60")
1057				w.WriteHeader(http.StatusOK)
1058				_, _ = w.Write([]byte("success"))
1059			})
1060
1061			logger := slog.Default()
1062			handler := NewHttpCache(logger, mux)
1063			tc := NewTestContext(t, handler)
1064
1065			req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
1066			cacheKey := handler.GetCacheKey(req)
1067
1068			cv := testCacheValue(250 * time.Second)
1069			if tt.cachedETag != "" {
1070				cv.Header["ETag"] = []string{tt.cachedETag}
1071			}
1072			if tt.cachedLastModified != "" {
1073				cv.Header["Last-Modified"] = []string{tt.cachedLastModified}
1074			}
1075			cv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
1076			cv.Body = []byte("cached body")
1077			cacheData := cv.encode()
1078			handler.Cache.Add(cacheKey, cacheData)
1079
1080			resp, _ := tc.Do(req)
1081
1082			// Client request is unconditional — after upstream 304, cache
1083			// serves the stored body as 200.
1084			if resp.StatusCode != http.StatusOK {
1085				t.Errorf("expected 200, got %d", resp.StatusCode)
1086			}
1087			status := resp.Header.Get("cache-status")
1088			if !strings.Contains(status, "hit") {
1089				t.Errorf("expected cache-status hit, got %s", status)
1090			}
1091
1092			if receivedRequest == nil {
1093				t.Fatal("no request reached upstream handler")
1094			}
1095
1096			if tt.expectedIfNoneMatch != "" && receivedIfNoneMatch != tt.expectedIfNoneMatch {
1097				t.Errorf("expected If-None-Match %q, got %q", tt.expectedIfNoneMatch, receivedIfNoneMatch)
1098			}
1099			if tt.expectedIfModified != "" && receivedIfModifiedSince != tt.expectedIfModified {
1100				t.Errorf("expected If-Modified-Since %q, got %q", tt.expectedIfModified, receivedIfModifiedSince)
1101			}
1102		})
1103	}
1104}
1105
1106// A range request that misses must not leave a partial response cached for
1107// requests of the whole resource.
1108func TestCacheRangeNotStored(t *testing.T) {
1109	body := strings.Repeat("0123456789", 100)
1110	upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1111		http.ServeContent(w, r, "file.txt", time.Unix(1_700_000_000, 0), strings.NewReader(body))
1112	})
1113	handler := NewHttpCache(slog.New(slog.DiscardHandler), upstream)
1114
1115	req := httptest.NewRequest("GET", "http://example.com/file.txt", nil)
1116	req.Header.Set("Range", "bytes=0-9")
1117	rec := httptest.NewRecorder()
1118	handler.ServeHTTP(rec, req)
1119	if rec.Code != http.StatusPartialContent || rec.Body.String() != body[:10] {
1120		t.Fatalf("range request got %d %q", rec.Code, rec.Body.String())
1121	}
1122
1123	for _, want := range []string{"miss", "hit"} {
1124		rec = httptest.NewRecorder()
1125		handler.ServeHTTP(rec, httptest.NewRequest("GET", "http://example.com/file.txt", nil))
1126		if rec.Code != http.StatusOK || rec.Body.String() != body {
1127			t.Fatalf("full request got %d with %d bytes", rec.Code, rec.Body.Len())
1128		}
1129		if status := rec.Header().Get("cache-status"); !strings.Contains(status, want) {
1130			t.Fatalf("full request cache-status %q, want %s", status, want)
1131		}
1132	}
1133}
1134
1135// Bodies over MaxBodySize stream through and are not stored, whether or not
1136// upstream announces their length.
1137func TestCacheMaxBodySize(t *testing.T) {
1138	body := strings.Repeat("x", 1000)
1139	for name, setLength := range map[string]bool{"content-length": true, "chunked": false} {
1140		t.Run(name, func(t *testing.T) {
1141			upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1142				if setLength {
1143					w.Header().Set("content-length", strconv.Itoa(len(body)))
1144				}
1145				for i := 0; i < len(body); i += 100 {
1146					_, _ = w.Write([]byte(body[i : i+100]))
1147				}
1148			})
1149			handler := NewHttpCache(slog.New(slog.DiscardHandler), upstream)
1150			handler.MaxBodySize = 250
1151
1152			for range 2 {
1153				rec := httptest.NewRecorder()
1154				handler.ServeHTTP(rec, httptest.NewRequest("GET", "http://example.com/big", nil))
1155				if rec.Code != http.StatusOK || rec.Body.String() != body {
1156					t.Fatalf("got %d with %d bytes", rec.Code, rec.Body.Len())
1157				}
1158				if status := rec.Header().Get("cache-status"); !strings.Contains(status, "miss") {
1159					t.Fatalf("cache-status %q, want a miss", status)
1160				}
1161			}
1162			if handler.Cache.Len() != 0 {
1163				t.Fatalf("stored %d entries", handler.Cache.Len())
1164			}
1165		})
1166	}
1167}