feeds_test.go 5.8 K raw
1
package main
2
3
import (
4
	"context"
5
	"fmt"
6
	"net/http"
7
	"net/http/httptest"
8
	"net/url"
9
	"strings"
10
	"sync/atomic"
11
	"testing"
12
	"time"
13
)
14
15
// --- inbound limiter -------------------------------------------------------
16
17
func TestLimitInFlightRejectsOverflow(t *testing.T) {
18
	entered := make(chan struct{})
19
	release := make(chan struct{})
20
	defer close(release) // unblock the parked handler when the test ends
21
	h := limitInFlight(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
		entered <- struct{}{}
23
		<-release
24
	}), 1)
25
26
	// Occupy the single slot with a request parked inside the handler.
27
	go h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
28
	<-entered
29
30
	// A second request must be shed immediately rather than queue.
31
	rec := httptest.NewRecorder()
32
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
33
	if rec.Code != http.StatusServiceUnavailable {
34
		t.Fatalf("overflow: want 503, got %d", rec.Code)
35
	}
36
}
37
38
func TestLimitInFlightStaticBypass(t *testing.T) {
39
	// Capacity 0: no non-static request can ever get a slot.
40
	h := limitInFlight(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41
		w.WriteHeader(http.StatusOK)
42
	}), 0)
43
44
	rec := httptest.NewRecorder()
45
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/static/app.css", nil))
46
	if rec.Code != http.StatusOK {
47
		t.Fatalf("static bypass: want 200, got %d", rec.Code)
48
	}
49
50
	rec = httptest.NewRecorder()
51
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
52
	if rec.Code != http.StatusServiceUnavailable {
53
		t.Fatalf("non-static: want 503, got %d", rec.Code)
54
	}
55
}
56
57
// --- outbound fetch limiter ------------------------------------------------
58
59
func TestAcquireFetchRespectsContext(t *testing.T) {
60
	saved := fetchSem
61
	fetchSem = make(chan struct{}, 1)
62
	defer func() { fetchSem = saved }()
63
64
	if err := acquireFetch(context.Background()); err != nil {
65
		t.Fatalf("first acquire: %v", err)
66
	}
67
	// Slot is full; a cancelled context must bail instead of blocking forever.
68
	ctx, cancel := context.WithCancel(context.Background())
69
	cancel()
70
	if err := acquireFetch(ctx); err == nil {
71
		t.Fatal("want error acquiring under cancelled ctx, got nil")
72
	}
73
	releaseFetch()
74
	if err := acquireFetch(context.Background()); err != nil {
75
		t.Fatalf("acquire after release: %v", err)
76
	}
77
	releaseFetch()
78
}
79
80
// --- request parsing -------------------------------------------------------
81
82
func TestFeedURLsFromRequestCap(t *testing.T) {
83
	parts := make([]string, 0, 30)
84
	for i := 0; i < 30; i++ {
85
		parts = append(parts, fmt.Sprintf("https://e%d.com/feed", i))
86
	}
87
	q := url.QueryEscape(strings.Join(parts, ","))
88
	req := httptest.NewRequest(http.MethodGet, "/?url="+q, nil)
89
	if got := len(feedURLsFromRequest(req)); got != maxFeedURLs {
90
		t.Fatalf("cap: want %d urls, got %d", maxFeedURLs, got)
91
	}
92
}
93
94
func TestFeedURLsFromRequestFallsBackToUrls(t *testing.T) {
95
	req := httptest.NewRequest(http.MethodGet, "/?urls=https://a.com/feed,https://b.com/feed", nil)
96
	if got := len(feedURLsFromRequest(req)); got != 2 {
97
		t.Fatalf("urls param: want 2, got %d", got)
98
	}
99
}
100
101
func TestOGCacheKeyOrderIndependent(t *testing.T) {
102
	a := ogCacheKey([]string{"https://b.com", "https://a.com"})
103
	b := ogCacheKey([]string{"https://a.com", "https://b.com"})
104
	if a != b {
105
		t.Fatalf("cache key not order-independent: %q vs %q", a, b)
106
	}
107
}
108
109
// --- feed cache ------------------------------------------------------------
110
111
func TestFeedCacheEvictsLRU(t *testing.T) {
112
	c := newFeedCache(time.Minute, 1)
113
	c.store("a", &FetchResult{Title: "a"})
114
	c.store("b", &FetchResult{Title: "b"})
115
	if _, ok := c.lookup("a"); ok {
116
		t.Fatal("a should have been evicted")
117
	}
118
	if _, ok := c.lookup("b"); !ok {
119
		t.Fatal("b should still be cached")
120
	}
121
}
122
123
func TestFeedCacheExpires(t *testing.T) {
124
	c := newFeedCache(time.Millisecond, 8)
125
	c.store("a", &FetchResult{Title: "a"})
126
	time.Sleep(5 * time.Millisecond)
127
	if _, ok := c.lookup("a"); ok {
128
		t.Fatal("entry should have expired")
129
	}
130
}
131
132
// --- image cache -----------------------------------------------------------
133
134
func TestImageCacheRendersOnce(t *testing.T) {
135
	c := newImageCache(time.Minute, 4)
136
	var calls int32
137
	render := func() ([]byte, error) {
138
		atomic.AddInt32(&calls, 1)
139
		return []byte("png"), nil
140
	}
141
	for i := 0; i < 3; i++ {
142
		if _, err := c.getOrRender("k", render); err != nil {
143
			t.Fatalf("getOrRender: %v", err)
144
		}
145
	}
146
	if calls != 1 {
147
		t.Fatalf("render calls: want 1, got %d", calls)
148
	}
149
}
150
151
// --- fetchFeed -------------------------------------------------------------
152
153
const sampleRSS = `<?xml version="1.0"?>
154
<rss version="2.0"><channel>
155
<title>Example</title><link>https://example.com</link>
156
<item><title>Hello</title><link>https://example.com/hello</link></item>
157
</channel></rss>`
158
159
func TestFetchFeedParses(t *testing.T) {
160
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
161
		w.Header().Set("ETag", `"v1"`)
162
		fmt.Fprint(w, sampleRSS)
163
	}))
164
	defer srv.Close()
165
166
	res, err := fetchFeed(context.Background(), srv.URL, "", "")
167
	if err != nil {
168
		t.Fatalf("fetchFeed: %v", err)
169
	}
170
	if res.Title != "Example" {
171
		t.Fatalf("title: want Example, got %q", res.Title)
172
	}
173
	if len(res.Entries) != 1 || res.Entries[0].Link != "https://example.com/hello" {
174
		t.Fatalf("entries: %+v", res.Entries)
175
	}
176
	if res.ETag != `"v1"` {
177
		t.Fatalf("etag: want v1, got %q", res.ETag)
178
	}
179
}
180
181
func TestFetchFeedNotModified(t *testing.T) {
182
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183
		if r.Header.Get("If-None-Match") == `"v1"` {
184
			w.WriteHeader(http.StatusNotModified)
185
			return
186
		}
187
		fmt.Fprint(w, sampleRSS)
188
	}))
189
	defer srv.Close()
190
191
	res, err := fetchFeed(context.Background(), srv.URL, `"v1"`, "")
192
	if err != nil {
193
		t.Fatalf("fetchFeed 304: %v", err)
194
	}
195
	if res.Status != http.StatusNotModified {
196
		t.Fatalf("status: want 304, got %d", res.Status)
197
	}
198
	if res.ETag != `"v1"` {
199
		t.Fatalf("etag carried forward: want v1, got %q", res.ETag)
200
	}
201
}