package steamcache import ( "bytes" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) func steamGet(t *testing.T, sc *SteamCache, path string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") rec := httptest.NewRecorder() sc.ServeHTTP(rec, req) return rec } func TestNegativeCache404(t *testing.T) { var upstreamHits atomic.Int64 f := func(w http.ResponseWriter, r *http.Request) { upstreamHits.Add(1) http.Error(w, "not found", http.StatusNotFound) } sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") sc.ResetMetrics() rec1 := steamGet(t, sc, "/depot/gone/chunk") if rec1.Code != http.StatusNotFound { t.Fatalf("first request: expected 404, got %d body=%q", rec1.Code, rec1.Body.String()) } if n := upstreamHits.Load(); n != 1 { t.Fatalf("first request: expected 1 upstream hit, got %d", n) } rec2 := steamGet(t, sc, "/depot/gone/chunk") if rec2.Code != http.StatusNotFound { t.Fatalf("second request: expected 404, got %d", rec2.Code) } if n := upstreamHits.Load(); n != 1 { t.Fatalf("repeated miss must not re-hit upstream within TTL, got %d", n) } stats := sc.GetMetrics() if stats.NegativeCacheHits < 1 { t.Errorf("expected NegativeCacheHits >= 1, got %d", stats.NegativeCacheHits) } if stats.CacheHits < 1 { t.Errorf("negative hit should also count as cache_hits, got %d", stats.CacheHits) } if stats.UpstreamErrors != 1 { t.Errorf("first 404 should count upstream error once, got %d", stats.UpstreamErrors) } mrec := httptest.NewRecorder() sc.ServeHTTP(mrec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) if !bytes.Contains(mrec.Body.Bytes(), []byte("negative_cache_hits")) { t.Errorf("/metrics missing negative_cache_hits:\n%s", mrec.Body.String()) } if ct := mrec.Header().Get("Content-Type"); ct != "text/plain; version=0.0.4; charset=utf-8" { t.Errorf("/metrics Content-Type=%q, want Prometheus text 0.0.4", ct) } } func TestNegativeCache410(t *testing.T) { var upstreamHits atomic.Int64 f := func(w http.ResponseWriter, r *http.Request) { upstreamHits.Add(1) w.WriteHeader(http.StatusGone) } sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") rec1 := steamGet(t, sc, "/depot/gone410/chunk") if rec1.Code != http.StatusGone { t.Fatalf("first request: expected 410, got %d", rec1.Code) } rec2 := steamGet(t, sc, "/depot/gone410/chunk") if rec2.Code != http.StatusGone { t.Fatalf("second request: expected 410, got %d", rec2.Code) } if n := upstreamHits.Load(); n != 1 { t.Fatalf("410 negative cache should suppress second upstream hit, got %d", n) } if sc.GetMetrics().NegativeCacheHits < 1 { t.Errorf("expected NegativeCacheHits >= 1 after cached 410, got %d", sc.GetMetrics().NegativeCacheHits) } } func TestNegativeCacheExpiredRefetch(t *testing.T) { var upstreamHits atomic.Int64 f := func(w http.ResponseWriter, r *http.Request) { upstreamHits.Add(1) http.Error(w, "not found", http.StatusNotFound) } s := httptest.NewServer(http.HandlerFunc(f)) t.Cleanup(s.Close) sc, err := NewWithOptions(Options{ Address: "127.0.0.1:0", MemorySize: "1MB", DiskSize: "0", DiskPath: t.TempDir(), Upstream: s.URL, MemoryGC: "lru", DiskGC: "lru", MaxConcurrentRequests: 10, MaxRequestsPerClient: 5, MaxObjectSize: "0", NegativeTTL: "1s", }) if err != nil { t.Fatalf("NewWithOptions: %v", err) } t.Cleanup(func() { sc.Shutdown() }) if rec := steamGet(t, sc, "/depot/ttl/chunk"); rec.Code != http.StatusNotFound { t.Fatalf("first request: expected 404, got %d", rec.Code) } if n := upstreamHits.Load(); n != 1 { t.Fatalf("first request: expected 1 upstream hit, got %d", n) } deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { time.Sleep(50 * time.Millisecond) rec := steamGet(t, sc, "/depot/ttl/chunk") if rec.Code != http.StatusNotFound { t.Fatalf("expected 404 after expiry poll, got %d", rec.Code) } if upstreamHits.Load() >= 2 { return } } t.Fatalf("expired negative entry did not re-fetch upstream; hits=%d", upstreamHits.Load()) } func TestSerializeNegativeHeader(t *testing.T) { raw := []byte("HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\ngone") pos, err := serializeRawResponse(raw) if err != nil { t.Fatalf("serialize positive: %v", err) } posFile, err := deserializeCacheFile(pos) if err != nil { t.Fatalf("deserialize positive: %v", err) } if posFile.ExpiresUnix != 0 { t.Errorf("positive entry ExpiresUnix=%d, want 0", posFile.ExpiresUnix) } expires := time.Now().Add(5 * time.Minute).Unix() neg, err := serializeCacheFile(raw, expires) if err != nil { t.Fatalf("serialize negative: %v", err) } negFile, err := deserializeCacheFile(neg) if err != nil { t.Fatalf("deserialize negative: %v", err) } if negFile.ExpiresUnix != expires { t.Errorf("ExpiresUnix=%d, want %d", negFile.ExpiresUnix, expires) } if !bytes.Equal(negFile.Response, raw) { t.Error("negative raw response not preserved") } } func TestNewInvalidNegativeTTL(t *testing.T) { sc, err := New("127.0.0.1:0", "1MB", "0", t.TempDir(), "", "lru", "lru", 10, 5, "0", nil, "not-a-duration", "", 0) if err == nil { if sc != nil { sc.Shutdown() } t.Fatal("expected error for invalid negative ttl") } if sc != nil { t.Error("expected nil SteamCache on invalid negative ttl") } if !strings.Contains(err.Error(), "invalid negative ttl") { t.Errorf("err %q missing invalid negative ttl", err) } } func TestWriteTextNegativeCacheHits(t *testing.T) { body := []byte("ok") f := func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(body) } sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") srv := newCacheServer(t, sc) c := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequest(http.MethodGet, srv.URL+"/metrics", nil) if err != nil { t.Fatalf("NewRequest: %v", err) } resp, err := c.Do(req) if err != nil { t.Fatalf("GET /metrics: %v", err) } out, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { t.Fatalf("read /metrics: %v", err) } if !bytes.Contains(out, []byte("negative_cache_hits 0\n")) { t.Errorf("/metrics missing negative_cache_hits 0:\n%s", out) } if ct := resp.Header.Get("Content-Type"); ct != "text/plain; version=0.0.4; charset=utf-8" { t.Errorf("/metrics Content-Type=%q, want Prometheus text 0.0.4", ct) } if !bytes.Contains(out, []byte("# HELP negative_cache_hits ")) { t.Errorf("/metrics missing # HELP negative_cache_hits:\n%s", out) } if !bytes.Contains(out, []byte("# TYPE negative_cache_hits counter")) { t.Errorf("/metrics missing # TYPE negative_cache_hits counter:\n%s", out) } }