// steamcache/range_test.go package steamcache import ( "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // TestRangeHitServedLocally verifies that a Range GET against an already-cached // object is served locally as 206 (no upstream re-fetch) and increments range_cache. func TestRangeHitServedLocally(t *testing.T) { body := []byte("0123456789abcdef") // 16 bytes var upstreamCalls atomic.Int64 f := func(w http.ResponseWriter, r *http.Request) { upstreamCalls.Add(1) w.Header().Set("Content-Type", "application/octet-stream") _, _ = w.Write(body) } sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") srv := newCacheServer(t, sc) c := &http.Client{Timeout: 5 * time.Second} // 1) Populate the cache with a full (non-Range) MISS. req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk", nil) if err != nil { t.Fatalf("NewRequest: %v", err) } req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") resp, err := c.Do(req) if err != nil { t.Fatalf("miss GET: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("miss GET: expected 200, got %d", resp.StatusCode) } _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() if got := sc.GetMetrics().CacheMisses; got < 1 { t.Fatalf("expected CacheMisses >= 1 after first GET, got %d", got) } // 2) Range GET against the same URL — must be a local 206 HIT. req2, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk", nil) if err != nil { t.Fatalf("NewRequest: %v", err) } req2.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") req2.Header.Set("Range", "bytes=4-7") resp2, err := c.Do(req2) if err != nil { t.Fatalf("range GET: %v", err) } data, err := io.ReadAll(resp2.Body) _ = resp2.Body.Close() if err != nil { t.Fatalf("read range body: %v", err) } if resp2.StatusCode != http.StatusPartialContent { t.Fatalf("range GET: expected 206, got %d", resp2.StatusCode) } if string(data) != "4567" { t.Errorf("range GET: expected body %q, got %q", "4567", data) } if got := resp2.Header.Get("X-LanCache-Status"); got != "HIT" { t.Errorf("range GET: expected X-LanCache-Status HIT, got %q", got) } if got := resp2.Header.Get("Content-Range"); got != "bytes 4-7/16" { t.Errorf("range GET: expected Content-Range bytes 4-7/16, got %q", got) } if got := resp2.Header.Get("Accept-Ranges"); got != "bytes" { t.Errorf("range GET: expected Accept-Ranges bytes, got %q", got) } // Upstream must NOT have been hit again. if got := upstreamCalls.Load(); got != 1 { t.Errorf("upstream hit %d times, want exactly 1 (range HIT must be local)", got) } // range_cache incremented, range_upstream untouched. stats := sc.GetMetrics() if stats.RangeCache != 1 { t.Errorf("expected RangeCache == 1 after range HIT, got %d", stats.RangeCache) } if stats.RangeUpstream != 0 { t.Errorf("expected RangeUpstream == 0 (no range miss yet), got %d", stats.RangeUpstream) } if stats.CacheHits < 1 { t.Errorf("expected CacheHits >= 1 after range HIT, got %d", stats.CacheHits) } // BytesServed: 16 (full miss body) + 4 (range bytes) = 20. if stats.TotalBytesServed != 20 { t.Errorf("expected TotalBytesServed == 20 (16 + range 4), got %d", stats.TotalBytesServed) } } // TestRangeMissServes206FromFullFetch verifies that a Range GET on a cold key fetches // the FULL object from upstream (Range stripped), caches it, and serves the requested // slice as 206 with range_upstream incremented. func TestRangeMissServes206FromFullFetch(t *testing.T) { body := []byte("0123456789abcdef") // 16 bytes var upstreamCalls atomic.Int64 var upstreamSawRange atomic.Bool f := func(w http.ResponseWriter, r *http.Request) { upstreamCalls.Add(1) if r.Header.Get("Range") != "" { upstreamSawRange.Store(true) } w.Header().Set("Content-Type", "application/octet-stream") _, _ = w.Write(body) } sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") srv := newCacheServer(t, sc) c := &http.Client{Timeout: 5 * time.Second} // Range GET on a cold key. req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk2", nil) if err != nil { t.Fatalf("NewRequest: %v", err) } req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") req.Header.Set("Range", "bytes=0-3") resp, err := c.Do(req) if err != nil { t.Fatalf("range miss GET: %v", err) } data, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { t.Fatalf("read body: %v", err) } if resp.StatusCode != http.StatusPartialContent { t.Fatalf("range miss GET: expected 206, got %d", resp.StatusCode) } if string(data) != "0123" { t.Errorf("range miss GET: expected body %q, got %q", "0123", data) } if got := resp.Header.Get("X-LanCache-Status"); got != "MISS" { t.Errorf("range miss GET: expected X-LanCache-Status MISS, got %q", got) } if got := resp.Header.Get("Content-Range"); got != "bytes 0-3/16" { t.Errorf("range miss GET: expected Content-Range bytes 0-3/16, got %q", got) } if got := resp.Header.Get("Accept-Ranges"); got != "bytes" { t.Errorf("range miss GET: expected Accept-Ranges bytes, got %q", got) } // Range must have been stripped for the upstream fetch (full file cached). if upstreamSawRange.Load() { t.Error("upstream received a Range header; Range must be stripped so the full object is cached") } if got := upstreamCalls.Load(); got != 1 { t.Errorf("upstream hit %d times, want exactly 1", got) } // range_upstream incremented, range_cache untouched. stats := sc.GetMetrics() if stats.RangeUpstream != 1 { t.Errorf("expected RangeUpstream == 1 after range MISS, got %d", stats.RangeUpstream) } if stats.RangeCache != 0 { t.Errorf("expected RangeCache == 0 (no range hit yet), got %d", stats.RangeCache) } if stats.CacheMisses < 1 { t.Errorf("expected CacheMisses >= 1, got %d", stats.CacheMisses) } // BytesServed for the range miss: only the 4 range bytes, not the 16-byte fetch. if stats.TotalBytesServed != 4 { t.Errorf("expected TotalBytesServed == 4 (range bytes only), got %d", stats.TotalBytesServed) } // The FULL object must have been cached: a subsequent full GET is a HIT with the // complete 16-byte body. req3, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk2", nil) if err != nil { t.Fatalf("NewRequest: %v", err) } req3.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") resp3, err := c.Do(req3) if err != nil { t.Fatalf("full GET after range miss: %v", err) } fullData, err := io.ReadAll(resp3.Body) _ = resp3.Body.Close() if err != nil { t.Fatalf("read full body: %v", err) } if resp3.StatusCode != http.StatusOK { t.Fatalf("full GET after range miss: expected 200, got %d", resp3.StatusCode) } if got := resp3.Header.Get("X-LanCache-Status"); got != "HIT" { t.Errorf("full GET after range miss: expected X-LanCache-Status HIT, got %q", got) } if len(fullData) != len(body) || string(fullData) != string(body) { t.Errorf("full GET after range miss: expected full %d-byte body, got %d bytes", len(body), len(fullData)) } if got := upstreamCalls.Load(); got != 1 { t.Errorf("upstream hit %d times after HIT, want still 1", got) } } // TestRangeMissInvalidRange416 verifies that an unsatisfiable Range on a cold key // fetches upstream, returns 416 (as on the HIT path), and does not count range_upstream. func TestRangeMissInvalidRange416(t *testing.T) { body := []byte("0123456789abcdef") // 16 bytes var upstreamCalls atomic.Int64 f := func(w http.ResponseWriter, r *http.Request) { upstreamCalls.Add(1) _, _ = w.Write(body) } sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") srv := newCacheServer(t, sc) c := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk3", nil) if err != nil { t.Fatalf("NewRequest: %v", err) } req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") req.Header.Set("Range", "bytes=100-200") resp, err := c.Do(req) if err != nil { t.Fatalf("invalid range GET: %v", err) } data, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { t.Fatalf("read body: %v", err) } if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable { t.Fatalf("invalid range GET: expected 416, got %d", resp.StatusCode) } if len(data) != 0 { t.Errorf("invalid range GET: expected empty body, got %d bytes", len(data)) } if got := resp.Header.Get("Content-Range"); got != "bytes */16" { t.Errorf("invalid range GET: expected Content-Range bytes */16, got %q", got) } if got := upstreamCalls.Load(); got != 1 { t.Errorf("upstream hit %d times, want exactly 1 (fetch happens, then 416 to client)", got) } stats := sc.GetMetrics() if stats.RangeUpstream != 0 { t.Errorf("expected RangeUpstream == 0 for unsatisfiable range, got %d", stats.RangeUpstream) } if stats.RangeCache != 0 { t.Errorf("expected RangeCache == 0, got %d", stats.RangeCache) } } // TestRangeMetricsWriteText verifies /metrics emits the range_cache and range_upstream // lines with the expected values after a range HIT and a range MISS. func TestRangeMetricsWriteText(t *testing.T) { body := []byte("0123456789abcdef") 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} get := func(path, rangeHeader string) int { t.Helper() req, err := http.NewRequest("GET", srv.URL+path, nil) if err != nil { t.Fatalf("NewRequest: %v", err) } req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") if rangeHeader != "" { req.Header.Set("Range", rangeHeader) } resp, err := c.Do(req) if err != nil { t.Fatalf("GET %s: %v", path, err) } _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() return resp.StatusCode } // Range MISS on cold key -> range_upstream; warm it; range HIT -> range_cache. if code := get("/depot/rangetest/wt/1", "bytes=0-3"); code != http.StatusPartialContent { t.Fatalf("range miss: expected 206, got %d", code) } if code := get("/depot/rangetest/wt/2", ""); code != http.StatusOK { t.Fatalf("warm miss: expected 200, got %d", code) } if code := get("/depot/rangetest/wt/2", "bytes=8-11"); code != http.StatusPartialContent { t.Fatalf("range hit: expected 206, got %d", code) } req, err := http.NewRequest("GET", 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) } text := string(out) if !strings.Contains(text, "range_cache 1\n") { t.Errorf("/metrics missing 'range_cache 1' line:\n%s", text) } if !strings.Contains(text, "range_upstream 1\n") { t.Errorf("/metrics missing 'range_upstream 1' line:\n%s", text) } 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 !strings.Contains(text, "# HELP range_cache ") { t.Errorf("/metrics missing # HELP range_cache:\n%s", text) } if !strings.Contains(text, "# TYPE range_cache counter") { t.Errorf("/metrics missing # TYPE range_cache counter:\n%s", text) } if !strings.Contains(text, "# TYPE range_upstream counter") { t.Errorf("/metrics missing # TYPE range_upstream counter:\n%s", text) } } // TestStreamCachedResponseRange206 is a focused unit test for streamCachedResponse: // valid Range yields 206 with the exact slice + metrics; invalid Range yields 416 // with no range metrics. func TestStreamCachedResponseRange206(t *testing.T) { body := []byte("0123456789abcdef") // 16 bytes raw := append([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n"), body...) serialized, err := serializeRawResponse(raw) if err != nil { t.Fatalf("serialize cache file: %v", err) } cf, err := deserializeCacheFile(serialized) if err != nil { t.Fatalf("build cache file: %v", err) } sc, _ := newTestCacheWithFakeUpstream(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("x")) }, "1MB", "0") sc.ResetMetrics() // Valid range -> 206 + slice + metrics. rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/depot/rangetest/chunk", nil) req.Header.Set("Range", "bytes=4-7") sc.streamCachedResponse(rec, req, cf, "steam/testkey", "127.0.0.1", time.Now()) if rec.Code != http.StatusPartialContent { t.Fatalf("expected 206, got %d", rec.Code) } if rec.Body.String() != "4567" { t.Errorf("expected body %q, got %q", "4567", rec.Body.String()) } if got := rec.Header().Get("Content-Range"); got != "bytes 4-7/16" { t.Errorf("expected Content-Range bytes 4-7/16, got %q", got) } if got := rec.Header().Get("X-LanCache-Status"); got != "HIT" { t.Errorf("expected X-LanCache-Status HIT, got %q", got) } stats := sc.GetMetrics() if stats.RangeCache != 1 { t.Errorf("expected RangeCache == 1, got %d", stats.RangeCache) } if stats.TotalBytesServed != 4 { t.Errorf("expected TotalBytesServed == 4 (range bytes), got %d", stats.TotalBytesServed) } if stats.TotalBytesSaved != 4 { t.Errorf("expected TotalBytesSaved == 4 (range bytes), got %d", stats.TotalBytesSaved) } // Invalid range -> 416, no range metrics, no bytes served. rec2 := httptest.NewRecorder() req2 := httptest.NewRequest("GET", "/depot/rangetest/chunk", nil) req2.Header.Set("Range", "bytes=100-200") sc.streamCachedResponse(rec2, req2, cf, "steam/testkey", "127.0.0.1", time.Now()) if rec2.Code != http.StatusRequestedRangeNotSatisfiable { t.Fatalf("expected 416, got %d", rec2.Code) } if got := rec2.Header().Get("Content-Range"); got != "bytes */16" { t.Errorf("expected Content-Range bytes */16, got %q", got) } stats = sc.GetMetrics() if stats.RangeCache != 1 { t.Errorf("RangeCache must stay 1 after unsatisfiable range, got %d", stats.RangeCache) } if stats.TotalBytesServed != 4 { t.Errorf("TotalBytesServed must stay 4 after 416, got %d", stats.TotalBytesServed) } }