app/apicache_test.go

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

205 lines · 5579 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, 0).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, 0)
 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
 88// A 500 here rather than a 403: a 403 is a block and starts the backoff,
 89// which is covered in apicache_stale_test.go.
 90func TestNon200IsNotStored(t *testing.T) {
 91	up := &fakeRT{status: 500, body: "upstream broke"}
 92	rt := newAPICache(1<<20, time.Minute, 0).transport(up)
 93
 94	status, body := get(t, rt, puppyURL)
 95	get(t, rt, puppyURL)
 96
 97	if status != 500 || body != "upstream broke" {
 98		t.Errorf("first response is %d %q, want the upstream 500 passed through", status, body)
 99	}
100	if up.count() != 2 {
101		t.Errorf("upstream called %d times, want 2: a 500 must not be cached", up.count())
102	}
103}
104
105func TestBypassesSessionAndOtherHosts(t *testing.T) {
106	up := &fakeRT{status: 200, body: "x"}
107	rt := newAPICache(1<<20, time.Minute, 0).transport(up)
108
109	for _, url := range []string{
110		"https://www.deviantart.com/_puppy",
111		"https://www.deviantart.com",
112		"https://a.deviantart.net/avatars-big/a/alice.png",
113	} {
114		get(t, rt, url)
115		get(t, rt, url)
116	}
117	if up.count() != 6 {
118		t.Errorf("upstream called %d times, want 6: none of these URLs may be cached", up.count())
119	}
120}
121
122func TestKeyIgnoresCSRFToken(t *testing.T) {
123	up := &fakeRT{status: 200, body: "x"}
124	rt := newAPICache(1<<20, time.Minute, 0).transport(up)
125
126	get(t, rt, puppyURL)
127	get(t, rt, strings.Replace(puppyURL, "csrf_token=abc", "csrf_token=def", 1))
128
129	if up.count() != 1 {
130		t.Errorf("upstream called %d times, want 1: a token refresh must not miss", up.count())
131	}
132}
133
134func TestByteBoundEvictsLeastRecentlyUsed(t *testing.T) {
135	up := &fakeRT{status: 200, body: strings.Repeat("x", 100)}
136	rt := newAPICache(250, time.Minute, 0).transport(up)
137	a := "https://www.deviantart.com/_puppy/a?p=1"
138	b := "https://www.deviantart.com/_puppy/b?p=1"
139	c := "https://www.deviantart.com/_puppy/c?p=1"
140
141	get(t, rt, a)
142	get(t, rt, b)
143	get(t, rt, a) // a is now more recent than b
144	get(t, rt, c) // 300 bytes would exceed 250: b goes
145	get(t, rt, a)
146	get(t, rt, c)
147	get(t, rt, b)
148
149	if up.count() != 4 {
150		t.Errorf("upstream called %d times, want 4: only b should have been evicted", up.count())
151	}
152}
153
154func TestConcurrentMissesMakeOneUpstreamCall(t *testing.T) {
155	up := &fakeRT{status: 200, body: "x", delay: 50 * time.Millisecond}
156	rt := newAPICache(1<<20, time.Minute, 0).transport(up)
157
158	var wg sync.WaitGroup
159	for range 20 {
160		wg.Go(func() { get(t, rt, puppyURL) })
161	}
162	wg.Wait()
163
164	if up.count() != 1 {
165		t.Errorf("upstream called %d times, want 1 for a burst on one key", up.count())
166	}
167}
168
169func TestStatsCountHitsAndMisses(t *testing.T) {
170	up := &fakeRT{status: 200, body: "abc"}
171	c := newAPICache(1<<20, time.Minute, 0)
172	rt := c.transport(up)
173
174	get(t, rt, puppyURL)
175	get(t, rt, puppyURL)
176	get(t, rt, puppyURL)
177
178	hits, misses, stale, entries, held := c.stats()
179	if hits != 2 || misses != 1 || stale != 0 || entries != 1 || held != 3 {
180		t.Errorf("stats = %d hits, %d misses, %d stale, %d entries, %d bytes; want 2, 1, 0, 1, 3", hits, misses, stale, entries, held)
181	}
182}
183
184// TestHitDoesNotConsumeAThrottleSlot pins the chain order: the cache sits in
185// front of the throttle, so a hit returns even when every throttle slot is
186// held. With the order reversed this test hangs and times out.
187func TestHitDoesNotConsumeAThrottleSlot(t *testing.T) {
188	up := &fakeRT{status: 200, body: "x"}
189	th := &daThrottle{base: up, sem: make(chan struct{}, 1)}
190	rt := newAPICache(1<<20, time.Minute, 0).transport(th)
191
192	get(t, rt, puppyURL) // populate through the throttle
193
194	th.sem <- struct{}{} // hold the only slot
195	done := make(chan struct{})
196	go func() {
197		get(t, rt, puppyURL)
198		close(done)
199	}()
200	select {
201	case <-done:
202	case <-time.After(2 * time.Second):
203		t.Fatal("a cache hit waited on the throttle")
204	}
205}