Skip to content

Commit f100c7e

Browse files
arpithArpith Siromoneyclaude
authored
Port feed.get function to Go with integration tests (#25)
* Add integration test infrastructure - Add docker-compose.yml with Redis and MinIO services - Update GitHub Actions workflow to run integration tests - Configure services with health checks and proper ports 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Add feed fixtures for testing - Add XKCD Atom feed fixture - Add Hacker News RSS feed fixture - Add invalid feed fixture for testing validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Add feed-get test cases - Define test scenarios for feed fetching and processing - Include tests for Atom and RSS feeds - Add tests for caching, validation, and S3 updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Implement FeedGetter in Go - Port feed.get function from Node.js to Go - Use gofeed for RSS/Atom parsing - Integrate with Redis for metadata and article storage - Integrate with S3 for article JSON storage - Support HTTP caching with If-Modified-Since and ETag headers - Handle feed parsing, validation, and storage logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * 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 <[email protected]> * Add complete expected values to feed-get tests - Add hash and score values for all RSS feed articles - Include all article metadata for comprehensive testing - Ensure both JS and Go tests use same expected values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Add JavaScript integration tests for feed fetching - Create integration tests that mirror Go integration tests - Test feed fetching with real Redis and S3 (MinIO) - Verify article storage in both Redis sorted sets and S3 - Use shared testdata/feed-get-tests.yaml for consistency - Tests run with INTEGRATION=true environment variable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Rename Go code to use Fetcher and FetchFeed - Rename feedgetter.go to fetcher.go - Rename feedgetter_integration_test.go to fetcher_integration_test.go - Rename FeedGetter struct to Fetcher - Rename NewFeedGetter function to NewFetcher - Rename Get method to FetchFeed - Update all test function names and references - Use consistent 'f' receiver variable instead of 'fg' 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Move hardcoded test values to YAML and use real feeds.js in tests - Add HTTP 304 caching test case to feed-get-tests.yaml with expected values - Update Go integration test to load caching values from YAML instead of hardcoded constants - Rewrite JavaScript integration tests to actually call feeds.js feed.get function - Use mock Express req/res objects to test the real implementation - Both tests now use shared YAML test data for consistency All tests now pull expected values from testdata/feed-get-tests.yaml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Arpith Siromoney <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 23dac43 commit f100c7e

File tree

11 files changed

+1294
-0
lines changed

11 files changed

+1294
-0
lines changed

.github/workflows/test.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,59 @@ jobs:
4848

4949
- name: Run Go tests
5050
run: go test -v ./feedfetcher/...
51+
52+
test-go-integration:
53+
runs-on: ubuntu-latest
54+
55+
services:
56+
redis:
57+
image: redis:7-alpine
58+
ports:
59+
- 6379:6379
60+
options: >-
61+
--health-cmd "redis-cli ping"
62+
--health-interval 10s
63+
--health-timeout 5s
64+
--health-retries 5
65+
66+
minio:
67+
image: minio/minio:latest
68+
ports:
69+
- 9000:9000
70+
env:
71+
MINIO_ROOT_USER: minioadmin
72+
MINIO_ROOT_PASSWORD: minioadmin
73+
options: >-
74+
--health-cmd "curl -f http://localhost:9000/minio/health/live"
75+
--health-interval 10s
76+
--health-timeout 5s
77+
--health-retries 5
78+
79+
steps:
80+
- uses: actions/checkout@v3
81+
82+
- name: Set up Go
83+
uses: actions/setup-go@v4
84+
with:
85+
go-version: '1.23'
86+
87+
- name: Install MinIO Client
88+
run: |
89+
wget https://dl.min.io/client/mc/release/linux-amd64/mc
90+
chmod +x mc
91+
sudo mv mc /usr/local/bin/
92+
93+
- name: Configure MinIO
94+
run: |
95+
mc alias set local http://localhost:9000 minioadmin minioadmin
96+
mc mb local/feedreader2018-articles || true
97+
98+
- name: Run integration tests
99+
env:
100+
REDIS_HOST: localhost
101+
REDIS_PORT: 6379
102+
S3_ENDPOINT: http://localhost:9000
103+
S3_ACCESS_KEY: minioadmin
104+
S3_SECRET_KEY: minioadmin
105+
S3_BUCKET: feedreader2018-articles
106+
run: go test -v -tags=integration ./feedfetcher/...

docker-compose.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
version: '3.8'
2+
3+
services:
4+
redis:
5+
image: redis:7-alpine
6+
ports:
7+
- "6379:6379"
8+
healthcheck:
9+
test: ["CMD", "redis-cli", "ping"]
10+
interval: 5s
11+
timeout: 3s
12+
retries: 5
13+
14+
minio:
15+
image: minio/minio:latest
16+
ports:
17+
- "9000:9000"
18+
- "9001:9001"
19+
environment:
20+
MINIO_ROOT_USER: minioadmin
21+
MINIO_ROOT_PASSWORD: minioadmin
22+
command: server /data --console-address ":9001"
23+
healthcheck:
24+
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
25+
interval: 5s
26+
timeout: 3s
27+
retries: 5
28+
volumes:
29+
- minio-data:/data
30+
31+
volumes:
32+
minio-data:

feedfetcher/fetcher.go

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package feedfetcher
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strconv"
10+
11+
"github.com/aws/aws-sdk-go-v2/aws"
12+
"github.com/aws/aws-sdk-go-v2/service/s3"
13+
"github.com/go-redis/redis/v8"
14+
"github.com/mmcdole/gofeed"
15+
)
16+
17+
type Fetcher struct {
18+
redisClient *redis.Client
19+
s3Client *s3.Client
20+
httpClient *http.Client
21+
s3Bucket string
22+
}
23+
24+
type FeedResponse struct {
25+
Success bool `json:"success"`
26+
Title string `json:"title,omitempty"`
27+
Link string `json:"link,omitempty"`
28+
LastModified string `json:"lastModified,omitempty"`
29+
Etag string `json:"etag,omitempty"`
30+
Articles []string `json:"articles"`
31+
StatusCode int `json:"statusCode,omitempty"`
32+
StatusMessage string `json:"statusMessage,omitempty"`
33+
}
34+
35+
func NewFetcher(redisClient *redis.Client, s3Client *s3.Client, s3Bucket string) *Fetcher {
36+
return &Fetcher{
37+
redisClient: redisClient,
38+
s3Client: s3Client,
39+
httpClient: &http.Client{},
40+
s3Bucket: s3Bucket,
41+
}
42+
}
43+
44+
func (f *Fetcher) FetchFeed(ctx context.Context, feedURI string) (*FeedResponse, error) {
45+
keys := BuildRedisKeys(feedURI)
46+
47+
// Fetch stored feed metadata from Redis
48+
storedFeed, err := f.redisClient.HGetAll(ctx, keys.FeedKey).Result()
49+
if err != nil && err != redis.Nil {
50+
return nil, fmt.Errorf("failed to get stored feed: %w", err)
51+
}
52+
53+
// Build request headers with cached etag/lastModified
54+
headers := BuildRequestHeaders(storedFeed["lastModified"], storedFeed["etag"])
55+
56+
// Fetch the feed
57+
req, err := http.NewRequestWithContext(ctx, "GET", feedURI, nil)
58+
if err != nil {
59+
return nil, fmt.Errorf("failed to create request: %w", err)
60+
}
61+
62+
for key, value := range headers {
63+
req.Header.Set(key, value)
64+
}
65+
66+
resp, err := f.httpClient.Do(req)
67+
if err != nil {
68+
return &FeedResponse{
69+
Success: false,
70+
StatusMessage: err.Error(),
71+
}, nil
72+
}
73+
defer resp.Body.Close()
74+
75+
// Handle HTTP status codes
76+
if resp.StatusCode == http.StatusNotModified {
77+
// Feed not modified, return cached articles
78+
articles, err := f.getArticleIds(ctx, keys.ArticlesKey)
79+
if err != nil {
80+
return nil, err
81+
}
82+
return &FeedResponse{
83+
Success: true,
84+
Title: storedFeed["title"],
85+
Link: storedFeed["link"],
86+
LastModified: storedFeed["lastModified"],
87+
Etag: storedFeed["etag"],
88+
Articles: articles,
89+
StatusCode: resp.StatusCode,
90+
}, nil
91+
}
92+
93+
if resp.StatusCode != http.StatusOK {
94+
return &FeedResponse{
95+
Success: false,
96+
StatusCode: resp.StatusCode,
97+
StatusMessage: resp.Status,
98+
}, nil
99+
}
100+
101+
// Parse the feed
102+
body, err := io.ReadAll(resp.Body)
103+
if err != nil {
104+
return nil, fmt.Errorf("failed to read response body: %w", err)
105+
}
106+
107+
fp := gofeed.NewParser()
108+
feed, err := fp.ParseString(string(body))
109+
if err != nil {
110+
return &FeedResponse{
111+
Success: false,
112+
StatusMessage: fmt.Sprintf("failed to parse feed: %v", err),
113+
}, nil
114+
}
115+
116+
// Store feed metadata in Redis
117+
lastModified := resp.Header.Get("Last-Modified")
118+
etag := resp.Header.Get("Etag")
119+
120+
feedMeta := map[string]interface{}{
121+
"title": feed.Title,
122+
"link": feed.Link,
123+
"lastModified": lastModified,
124+
"etag": etag,
125+
}
126+
127+
if err := f.redisClient.HMSet(ctx, keys.FeedKey, feedMeta).Err(); err != nil {
128+
return nil, fmt.Errorf("failed to store feed metadata: %w", err)
129+
}
130+
131+
// Process articles
132+
for _, item := range feed.Items {
133+
article := Article{
134+
GUID: item.GUID,
135+
Title: item.Title,
136+
Description: item.Description,
137+
}
138+
139+
// Use published date or updated date
140+
if item.PublishedParsed != nil {
141+
article.PubDate = item.PublishedParsed.Format("2006-01-02T15:04:05Z07:00")
142+
} else if item.UpdatedParsed != nil {
143+
article.PubDate = item.UpdatedParsed.Format("2006-01-02T15:04:05Z07:00")
144+
}
145+
146+
// Validate article
147+
if !IsValidArticle(&article) {
148+
continue
149+
}
150+
151+
// Process article
152+
processedArticle := ProcessArticle(article, feedURI)
153+
articleKey := BuildArticleKey(processedArticle.Hash)
154+
155+
// Get old score from Redis
156+
oldScoreStr, err := f.redisClient.ZScore(ctx, keys.ArticlesKey, articleKey).Result()
157+
var oldScore *string
158+
if err == nil {
159+
scoreStr := strconv.FormatInt(int64(oldScoreStr), 10)
160+
oldScore = &scoreStr
161+
}
162+
163+
// Add article to sorted set
164+
err = f.redisClient.ZAdd(ctx, keys.ArticlesKey, &redis.Z{
165+
Score: float64(processedArticle.Score),
166+
Member: articleKey,
167+
}).Err()
168+
if err != nil {
169+
return nil, fmt.Errorf("failed to add article to sorted set: %w", err)
170+
}
171+
172+
// Store article in S3 if score changed
173+
if ShouldStoreArticle(oldScore, processedArticle.Score) {
174+
if err := f.storeArticleInS3(ctx, processedArticle); err != nil {
175+
return nil, fmt.Errorf("failed to store article in S3: %w", err)
176+
}
177+
}
178+
}
179+
180+
// Get all article IDs
181+
articles, err := f.getArticleIds(ctx, keys.ArticlesKey)
182+
if err != nil {
183+
return nil, err
184+
}
185+
186+
return &FeedResponse{
187+
Success: true,
188+
Title: feed.Title,
189+
Link: feed.Link,
190+
LastModified: lastModified,
191+
Etag: etag,
192+
Articles: articles,
193+
StatusCode: resp.StatusCode,
194+
}, nil
195+
}
196+
197+
func (f *Fetcher) storeArticleInS3(ctx context.Context, article Article) error {
198+
body, err := GenerateArticleBody(article)
199+
if err != nil {
200+
return err
201+
}
202+
203+
_, err = f.s3Client.PutObject(ctx, &s3.PutObjectInput{
204+
Bucket: aws.String(f.s3Bucket),
205+
Key: aws.String(article.Hash + ".json"),
206+
Body: bytes.NewReader([]byte(body)),
207+
ContentType: aws.String("application/json"),
208+
})
209+
210+
return err
211+
}
212+
213+
func (f *Fetcher) getArticleIds(ctx context.Context, articlesKey string) ([]string, error) {
214+
allArticles, err := f.redisClient.ZRevRange(ctx, articlesKey, 0, -1).Result()
215+
if err != nil {
216+
return nil, fmt.Errorf("failed to get articles from sorted set: %w", err)
217+
}
218+
219+
return ExtractArticleIds(allArticles), nil
220+
}

0 commit comments

Comments
 (0)