app/apicache_test.go

v1.5.0
skunky-art/app/apicache_test.go history · blame · raw

203 lines · 5376 bytes

  1package app
  2
  3import (
  4	"io"
  5	"net/http"
  6	"strings"
  7	"sync"
  8	"testing"
  9	"time"
 10)
 11
 12// fakeRT is the upstream: it counts calls and returns a scripted response.
 13type fakeRT struct {
 14	mu     sync.Mutex
 15	calls  int
 16	status int
 17	body   string
 18	delay  time.Duration
 19}
 20
 21func (f *fakeRT) RoundTrip(r *http.Request) (*http.Response, error) {
 22	f.mu.Lock()
 23	f.calls++
 24	f.mu.Unlock()
 25	time.Sleep(f.delay)
 26	return &http.Response{
 27		StatusCode: f.status,
 28		Header:     http.Header{"Content-Type": {"application/json"}},
 29		Body:       io.NopCloser(strings.NewReader(f.body)),
 30		Request:    r,
 31	}, nil
 32}
 33
 34func (f *fakeRT) count() int {
 35	f.mu.Lock()
 36	defer f.mu.Unlock()
 37	return f.calls
 38}
 39
 40const puppyURL = "https://www.deviantart.com/_puppy/dabrowse/search/all?q=fox&csrf_token=abc"
 41
 42func get(t *testing.T, rt http.RoundTripper, url string) (int, string) {
 43	t.Helper()
 44	req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:noctx // test request
 45	if err != nil {
 46		t.Fatal(err)
 47	}
 48	resp, err := rt.RoundTrip(req)
 49	if err != nil {
 50		t.Fatal(err)
 51	}
 52	defer func() { _ = resp.Body.Close() }()
 53	body, _ := io.ReadAll(resp.Body)
 54	return resp.StatusCode, string(body)
 55}
 56
 57func TestSecondRequestIsServedFromCache(t *testing.T) {
 58	up := &fakeRT{status: 200, body: `{"a":1}`}
 59	rt := newAPICache(1<<20, time.Minute).transport(up)
 60
 61	get(t, rt, puppyURL)
 62	status, body := get(t, rt, puppyURL)
 63
 64	if up.count() != 1 {
 65		t.Errorf("upstream called %d times, want 1", up.count())
 66	}
 67	if status != 200 || body != `{"a":1}` {
 68		t.Errorf("cached response is %d %q", status, body)
 69	}
 70}
 71
 72func TestExpiredEntryIsRefetched(t *testing.T) {
 73	up := &fakeRT{status: 200, body: `{}`}
 74	c := newAPICache(1<<20, time.Minute)
 75	now := time.Now()
 76	c.now = func() time.Time { return now }
 77	rt := c.transport(up)
 78
 79	get(t, rt, puppyURL)
 80	now = now.Add(2 * time.Minute)
 81	get(t, rt, puppyURL)
 82
 83	if up.count() != 2 {
 84		t.Errorf("upstream called %d times, want 2 after expiry", up.count())
 85	}
 86}
 87
 88func TestNon200IsNotStored(t *testing.T) {
 89	up := &fakeRT{status: 403, body: "blocked"}
 90	rt := newAPICache(1<<20, time.Minute).transport(up)
 91
 92	status, body := get(t, rt, puppyURL)
 93	get(t, rt, puppyURL)
 94
 95	if status != 403 || body != "blocked" {
 96		t.Errorf("first response is %d %q, want the upstream 403 passed through", status, body)
 97	}
 98	if up.count() != 2 {
 99		t.Errorf("upstream called %d times, want 2: a 403 must not be cached", up.count())
100	}
101}
102
103func TestBypassesSessionAndOtherHosts(t *testing.T) {
104	up := &fakeRT{status: 200, body: "x"}
105	rt := newAPICache(1<<20, time.Minute).transport(up)
106
107	for _, url := range []string{
108		"https://www.deviantart.com/_puppy",
109		"https://www.deviantart.com",
110		"https://a.deviantart.net/avatars-big/a/alice.png",
111	} {
112		get(t, rt, url)
113		get(t, rt, url)
114	}
115	if up.count() != 6 {
116		t.Errorf("upstream called %d times, want 6: none of these URLs may be cached", up.count())
117	}
118}
119
120func TestKeyIgnoresCSRFToken(t *testing.T) {
121	up := &fakeRT{status: 200, body: "x"}
122	rt := newAPICache(1<<20, time.Minute).transport(up)
123
124	get(t, rt, puppyURL)
125	get(t, rt, strings.Replace(puppyURL, "csrf_token=abc", "csrf_token=def", 1))
126
127	if up.count() != 1 {
128		t.Errorf("upstream called %d times, want 1: a token refresh must not miss", up.count())
129	}
130}
131
132func TestByteBoundEvictsLeastRecentlyUsed(t *testing.T) {
133	up := &fakeRT{status: 200, body: strings.Repeat("x", 100)}
134	rt := newAPICache(250, time.Minute).transport(up)
135	a := "https://www.deviantart.com/_puppy/a?p=1"
136	b := "https://www.deviantart.com/_puppy/b?p=1"
137	c := "https://www.deviantart.com/_puppy/c?p=1"
138
139	get(t, rt, a)
140	get(t, rt, b)
141	get(t, rt, a) // a is now more recent than b
142	get(t, rt, c) // 300 bytes would exceed 250: b goes
143	get(t, rt, a)
144	get(t, rt, c)
145	get(t, rt, b)
146
147	if up.count() != 4 {
148		t.Errorf("upstream called %d times, want 4: only b should have been evicted", up.count())
149	}
150}
151
152func TestConcurrentMissesMakeOneUpstreamCall(t *testing.T) {
153	up := &fakeRT{status: 200, body: "x", delay: 50 * time.Millisecond}
154	rt := newAPICache(1<<20, time.Minute).transport(up)
155
156	var wg sync.WaitGroup
157	for range 20 {
158		wg.Go(func() { get(t, rt, puppyURL) })
159	}
160	wg.Wait()
161
162	if up.count() != 1 {
163		t.Errorf("upstream called %d times, want 1 for a burst on one key", up.count())
164	}
165}
166
167func TestStatsCountHitsAndMisses(t *testing.T) {
168	up := &fakeRT{status: 200, body: "abc"}
169	c := newAPICache(1<<20, time.Minute)
170	rt := c.transport(up)
171
172	get(t, rt, puppyURL)
173	get(t, rt, puppyURL)
174	get(t, rt, puppyURL)
175
176	hits, misses, entries, held := c.stats()
177	if hits != 2 || misses != 1 || entries != 1 || held != 3 {
178		t.Errorf("stats = %d hits, %d misses, %d entries, %d bytes; want 2, 1, 1, 3", hits, misses, entries, held)
179	}
180}
181
182// TestHitDoesNotConsumeAThrottleSlot pins the chain order: the cache sits in
183// front of the throttle, so a hit returns even when every throttle slot is
184// held. With the order reversed this test hangs and times out.
185func TestHitDoesNotConsumeAThrottleSlot(t *testing.T) {
186	up := &fakeRT{status: 200, body: "x"}
187	th := &daThrottle{base: up, sem: make(chan struct{}, 1)}
188	rt := newAPICache(1<<20, time.Minute).transport(th)
189
190	get(t, rt, puppyURL) // populate through the throttle
191
192	th.sem <- struct{}{} // hold the only slot
193	done := make(chan struct{})
194	go func() {
195		get(t, rt, puppyURL)
196		close(done)
197	}()
198	select {
199	case <-done:
200	case <-time.After(2 * time.Second):
201		t.Fatal("a cache hit waited on the throttle")
202	}
203}