package imageproxy import ( "bytes" "context" "image/color" "io" "io/fs" "testing" "sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/imagerequest" ) // TestProxy_Get_CountsStats walks Get through a miss that fetches the // source, a hit, a miss that reuses the cached source, and two misses whose // source cannot be used, checking every counter of the proxy and its cache // after each. func TestProxy_Get_CountsStats(t *testing.T) { t.Parallel() svc, fixtures := setupTestProxy(t) // NewTestFS builds the same files the test proxy's fetcher serves. testFS, _ := NewTestFS(t) photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG) if err != nil { t.Fatal(err) } fake, err := fs.ReadFile(testFS, fixtures.InvalidFile) if err != nil { t.Fatal(err) } photoBytes, fakeBytes := int64(len(photo)), int64(len(fake)) // want is hits, misses, upstream fetches, upstream bytes, transforms. steps := []struct { name string path string size int wantErr bool want counterValues }{ {"miss that fetches the source", testPathPhoto, 50, false, counterValues{0, 1, 1, photoBytes, 1}}, {"hit", testPathPhoto, 50, false, counterValues{1, 1, 1, photoBytes, 1}}, {"miss that reuses the cached source", testPathPhoto, 25, false, counterValues{1, 2, 1, photoBytes, 2}}, {"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true, counterValues{1, 3, 2, photoBytes + fakeBytes, 2}}, {"miss whose source is not found", "/images/nonexistent.jpg", 50, true, counterValues{1, 4, 2, photoBytes + fakeBytes, 2}}, } for _, step := range steps { resp, err := svc.Get(t.Context(), &imagerequest.Request{ Source: imagerequest.Source{ Host: fixtures.GoodHost, Path: step.path, }, Transform: imagerequest.Transform{ Size: imagerequest.Size{Width: step.size, Height: step.size}, Format: imagerequest.FormatJPEG, Quality: 85, FitMode: imagerequest.FitCover, }, }) if (err != nil) != step.wantErr { t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr) } if err == nil { _ = resp.Content.Close() } got := readCounters(t, svc) if got != step.want { t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want) } } } // fakeUpstream answers every fetch with itself as a JPEG body. The body // serves data, then calls cancel, when set, and returns err; io.EOF ends // the body normally. type fakeUpstream struct { data *bytes.Reader cancel context.CancelFunc err error } func (u *fakeUpstream) Fetch( context.Context, imagerequest.Source, ) (*httpfetcher.FetchResult, error) { return &httpfetcher.FetchResult{ Content: io.NopCloser(u), ContentLength: -1, ContentType: testContentTypeJPEG, }, nil } func (u *fakeUpstream) Read(p []byte) (int, error) { if u.data.Len() > 0 { return u.data.Read(p) } if u.cancel != nil { u.cancel() } return 0, u.err } // TestProxy_Get_CountsInterruptedMisses checks every counter of the proxy // and its cache after a miss whose request context ends during or after the // upstream fetch, and after a miss whose upstream body is over the size // limit. func TestProxy_Get_CountsInterruptedMisses(t *testing.T) { t.Parallel() photo := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}) half := len(photo) / 2 // want is hits, misses, upstream fetches, upstream bytes, transforms. tests := []struct { name string served int // bytes of the photo the upstream body serves cancel bool // whether the body then ends the request context readErr error // what the body then returns wantErr bool want counterValues }{ {"request context ends during the fetch", half, true, context.Canceled, true, counterValues{0, 1, 1, int64(half), 0}}, {"request context ends after the fetch", len(photo), true, io.EOF, false, counterValues{0, 1, 1, int64(len(photo)), 1}}, {"upstream body over the size limit", half, false, httpfetcher.ErrResponseTooLarge, true, counterValues{0, 1, 1, int64(half), 0}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() svc, fixtures := setupTestProxy(t) ctx, cancel := context.WithCancel(t.Context()) defer cancel() upstream := &fakeUpstream{ data: bytes.NewReader(photo[:tc.served]), err: tc.readErr, } if tc.cancel { upstream.cancel = cancel } svc.fetcher = upstream resp, err := svc.Get(ctx, &imagerequest.Request{ Source: imagerequest.Source{ Host: fixtures.GoodHost, Path: testPathPhoto, }, Transform: imagerequest.Transform{ Size: imagerequest.Size{Width: 50, Height: 50}, Format: imagerequest.FormatJPEG, Quality: 85, FitMode: imagerequest.FitCover, }, }) t.Logf("Get() error = %v", err) if (err != nil) != tc.wantErr { t.Fatalf("Get() error = %v, want error %t", err, tc.wantErr) } if err == nil { _ = resp.Content.Close() } got := readCounters(t, svc) if got != tc.want { t.Errorf("counters = %+v, want %+v", got, tc.want) } }) } } // TestProxy_Get_CountsHitAfterRequestEnds checks every counter of the // proxy and its cache after a hit served with a request context that has // already ended: only the hit count moves. func TestProxy_Get_CountsHitAfterRequestEnds(t *testing.T) { t.Parallel() svc, fixtures := setupTestProxy(t) req := &imagerequest.Request{ Source: imagerequest.Source{ Host: fixtures.GoodHost, Path: testPathPhoto, }, Transform: imagerequest.Transform{ Size: imagerequest.Size{Width: 50, Height: 50}, Format: imagerequest.FormatJPEG, Quality: 85, FitMode: imagerequest.FitCover, }, } // A first request caches the variant. resp, err := svc.Get(t.Context(), req) if err != nil { t.Fatalf("first Get() error = %v", err) } _ = resp.Content.Close() want := readCounters(t, svc) want.hitCount++ ctx, cancel := context.WithCancel(t.Context()) cancel() resp, err = svc.Get(ctx, req) if err != nil { t.Fatalf("Get() with an ended request context: error = %v", err) } _ = resp.Content.Close() if resp.CacheStatus != CacheHit { t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit) } got := readCounters(t, svc) if got != want { t.Errorf("counters = %+v, want %+v", got, want) } }