app/apicache_test.go
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}