Skip to content

Commit 4f1d88b

Browse files
Arpith Siromoneyclaude
andcommitted
Add integration tests for FeedGetter
- Test feed fetching with real Redis and S3 (MinIO) - Use build tags to separate integration tests from unit tests - Test HTTP caching with 304 Not Modified responses - Verify article storage in both Redis sorted sets and S3 - Use feed fixtures for reproducible testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 065058c commit 4f1d88b

File tree

1 file changed

+304
-0
lines changed

1 file changed

+304
-0
lines changed
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
//go:build integration
2+
// +build integration
3+
4+
package feedfetcher
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/http"
10+
"net/http/httptest"
11+
"os"
12+
"testing"
13+
14+
"github.com/aws/aws-sdk-go-v2/aws"
15+
"github.com/aws/aws-sdk-go-v2/config"
16+
"github.com/aws/aws-sdk-go-v2/credentials"
17+
"github.com/aws/aws-sdk-go-v2/service/s3"
18+
"github.com/go-redis/redis/v8"
19+
"gopkg.in/yaml.v3"
20+
)
21+
22+
type FeedGetTestCases struct {
23+
FeedGetTests []struct {
24+
Description string `yaml:"description"`
25+
FeedFixture string `yaml:"feed_fixture"`
26+
FeedURI string `yaml:"feed_uri"`
27+
ExpectedFeedMetadata struct {
28+
Title string `yaml:"title"`
29+
Link string `yaml:"link"`
30+
} `yaml:"expected_feed_metadata"`
31+
ExpectedArticlesCount int `yaml:"expected_articles_count"`
32+
ExpectedArticles []struct {
33+
GUID string `yaml:"guid"`
34+
Title string `yaml:"title"`
35+
Hash string `yaml:"hash"`
36+
Score int64 `yaml:"score"`
37+
FeedURL string `yaml:"feedurl"`
38+
} `yaml:"expected_articles"`
39+
} `yaml:"feed_get_tests"`
40+
}
41+
42+
func setupRedisClient() (*redis.Client, error) {
43+
host := os.Getenv("REDIS_HOST")
44+
if host == "" {
45+
host = "localhost"
46+
}
47+
port := os.Getenv("REDIS_PORT")
48+
if port == "" {
49+
port = "6379"
50+
}
51+
52+
client := redis.NewClient(&redis.Options{
53+
Addr: fmt.Sprintf("%s:%s", host, port),
54+
})
55+
56+
ctx := context.Background()
57+
if err := client.Ping(ctx).Err(); err != nil {
58+
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
59+
}
60+
61+
return client, nil
62+
}
63+
64+
func setupS3Client() (*s3.Client, error) {
65+
endpoint := os.Getenv("S3_ENDPOINT")
66+
if endpoint == "" {
67+
endpoint = "http://localhost:9000"
68+
}
69+
70+
accessKey := os.Getenv("S3_ACCESS_KEY")
71+
if accessKey == "" {
72+
accessKey = "minioadmin"
73+
}
74+
75+
secretKey := os.Getenv("S3_SECRET_KEY")
76+
if secretKey == "" {
77+
secretKey = "minioadmin"
78+
}
79+
80+
cfg, err := config.LoadDefaultConfig(context.Background(),
81+
config.WithRegion("us-east-1"),
82+
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
83+
)
84+
if err != nil {
85+
return nil, fmt.Errorf("failed to load AWS config: %w", err)
86+
}
87+
88+
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
89+
o.BaseEndpoint = aws.String(endpoint)
90+
o.UsePathStyle = true
91+
})
92+
93+
return client, nil
94+
}
95+
96+
func TestFeedGetIntegration(t *testing.T) {
97+
// Load test cases
98+
data, err := os.ReadFile("../testdata/feed-get-tests.yaml")
99+
if err != nil {
100+
t.Fatalf("Failed to read test data: %v", err)
101+
}
102+
103+
var testCases FeedGetTestCases
104+
if err := yaml.Unmarshal(data, &testCases); err != nil {
105+
t.Fatalf("Failed to parse test data: %v", err)
106+
}
107+
108+
// Setup Redis and S3 clients
109+
redisClient, err := setupRedisClient()
110+
if err != nil {
111+
t.Fatalf("Failed to setup Redis client: %v", err)
112+
}
113+
defer redisClient.Close()
114+
115+
s3Client, err := setupS3Client()
116+
if err != nil {
117+
t.Fatalf("Failed to setup S3 client: %v", err)
118+
}
119+
120+
bucket := os.Getenv("S3_BUCKET")
121+
if bucket == "" {
122+
bucket = "feedreader2018-articles"
123+
}
124+
125+
// Run tests
126+
for _, tc := range testCases.FeedGetTests {
127+
t.Run(tc.Description, func(t *testing.T) {
128+
ctx := context.Background()
129+
130+
// Clear Redis data for this test
131+
keys := BuildRedisKeys(tc.FeedURI)
132+
redisClient.Del(ctx, keys.FeedKey, keys.ArticlesKey)
133+
134+
// Load feed fixture
135+
feedData, err := os.ReadFile(tc.FeedFixture)
136+
if err != nil {
137+
t.Fatalf("Failed to read feed fixture: %v", err)
138+
}
139+
140+
// Create test HTTP server to serve the feed
141+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
142+
w.Header().Set("Content-Type", "application/xml")
143+
w.Write(feedData)
144+
}))
145+
defer server.Close()
146+
147+
// Create FeedGetter and fetch the feed
148+
fg := NewFeedGetter(redisClient, s3Client, bucket)
149+
result, err := fg.Get(ctx, server.URL)
150+
if err != nil {
151+
t.Fatalf("FeedGetter.Get() failed: %v", err)
152+
}
153+
154+
// Verify success
155+
if !result.Success {
156+
t.Errorf("Expected success=true, got success=%v, message=%s", result.Success, result.StatusMessage)
157+
}
158+
159+
// Verify feed metadata
160+
if tc.ExpectedFeedMetadata.Title != "" {
161+
if result.Title != tc.ExpectedFeedMetadata.Title {
162+
t.Errorf("Title mismatch: got %s, want %s", result.Title, tc.ExpectedFeedMetadata.Title)
163+
}
164+
}
165+
166+
if tc.ExpectedFeedMetadata.Link != "" {
167+
if result.Link != tc.ExpectedFeedMetadata.Link {
168+
t.Errorf("Link mismatch: got %s, want %s", result.Link, tc.ExpectedFeedMetadata.Link)
169+
}
170+
}
171+
172+
// Verify article count
173+
if len(result.Articles) != tc.ExpectedArticlesCount {
174+
t.Errorf("Article count mismatch: got %d, want %d", len(result.Articles), tc.ExpectedArticlesCount)
175+
}
176+
177+
// Verify specific articles if provided
178+
for _, expectedArticle := range tc.ExpectedArticles {
179+
articleKey := BuildArticleKey(expectedArticle.Hash)
180+
found := false
181+
for _, article := range result.Articles {
182+
if article == expectedArticle.Hash {
183+
found = true
184+
break
185+
}
186+
}
187+
188+
if !found {
189+
t.Errorf("Expected article %s not found in results", expectedArticle.Hash)
190+
}
191+
192+
// Verify article is in Redis sorted set
193+
score, err := redisClient.ZScore(ctx, keys.ArticlesKey, articleKey).Result()
194+
if err != nil {
195+
t.Errorf("Article %s not found in Redis sorted set: %v", articleKey, err)
196+
} else if int64(score) != expectedArticle.Score {
197+
t.Errorf("Article score mismatch: got %d, want %d", int64(score), expectedArticle.Score)
198+
}
199+
200+
// Verify article is in S3
201+
_, err = s3Client.HeadObject(ctx, &s3.HeadObjectInput{
202+
Bucket: aws.String(bucket),
203+
Key: aws.String(expectedArticle.Hash + ".json"),
204+
})
205+
if err != nil {
206+
t.Errorf("Article %s not found in S3: %v", expectedArticle.Hash, err)
207+
}
208+
}
209+
})
210+
}
211+
}
212+
213+
func TestFeedGetCaching(t *testing.T) {
214+
ctx := context.Background()
215+
216+
// Setup clients
217+
redisClient, err := setupRedisClient()
218+
if err != nil {
219+
t.Fatalf("Failed to setup Redis client: %v", err)
220+
}
221+
defer redisClient.Close()
222+
223+
s3Client, err := setupS3Client()
224+
if err != nil {
225+
t.Fatalf("Failed to setup S3 client: %v", err)
226+
}
227+
228+
bucket := os.Getenv("S3_BUCKET")
229+
if bucket == "" {
230+
bucket = "feedreader2018-articles"
231+
}
232+
233+
// Load feed fixture
234+
feedData, err := os.ReadFile("../testdata/feeds/xkcd.atom.xml")
235+
if err != nil {
236+
t.Fatalf("Failed to read feed fixture: %v", err)
237+
}
238+
239+
requestCount := 0
240+
lastModified := "Wed, 09 Oct 2024 12:00:00 GMT"
241+
etag := "\"test-etag-123\""
242+
243+
// Create test server
244+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
245+
requestCount++
246+
247+
// On second request, check for caching headers and return 304
248+
if requestCount > 1 {
249+
if r.Header.Get("If-Modified-Since") != lastModified {
250+
t.Errorf("Expected If-Modified-Since header: %s, got: %s", lastModified, r.Header.Get("If-Modified-Since"))
251+
}
252+
if r.Header.Get("If-None-Match") != etag {
253+
t.Errorf("Expected If-None-Match header: %s, got: %s", etag, r.Header.Get("If-None-Match"))
254+
}
255+
w.WriteHeader(http.StatusNotModified)
256+
return
257+
}
258+
259+
// First request returns full feed
260+
w.Header().Set("Content-Type", "application/xml")
261+
w.Header().Set("Last-Modified", lastModified)
262+
w.Header().Set("Etag", etag)
263+
w.Write(feedData)
264+
}))
265+
defer server.Close()
266+
267+
fg := NewFeedGetter(redisClient, s3Client, bucket)
268+
269+
// Clear Redis
270+
keys := BuildRedisKeys(server.URL)
271+
redisClient.Del(ctx, keys.FeedKey, keys.ArticlesKey)
272+
273+
// First request
274+
result1, err := fg.Get(ctx, server.URL)
275+
if err != nil {
276+
t.Fatalf("First request failed: %v", err)
277+
}
278+
279+
if !result1.Success {
280+
t.Errorf("First request should succeed")
281+
}
282+
283+
if len(result1.Articles) != 3 {
284+
t.Errorf("Expected 3 articles, got %d", len(result1.Articles))
285+
}
286+
287+
// Second request (should use caching)
288+
result2, err := fg.Get(ctx, server.URL)
289+
if err != nil {
290+
t.Fatalf("Second request failed: %v", err)
291+
}
292+
293+
if !result2.Success {
294+
t.Errorf("Second request should succeed")
295+
}
296+
297+
if result2.StatusCode != http.StatusNotModified {
298+
t.Errorf("Expected 304 Not Modified, got %d", result2.StatusCode)
299+
}
300+
301+
if len(result2.Articles) != 3 {
302+
t.Errorf("Expected 3 articles from cache, got %d", len(result2.Articles))
303+
}
304+
}

0 commit comments

Comments
 (0)