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