package client_test
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
"github.com/0x2e/fusion/model"
"github.com/0x2e/fusion/pkg/ptr"
"github.com/0x2e/fusion/service/pull/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockReadCloser is a mock io.ReadCloser that can return either data or an error.
type mockReadCloser struct {
result string
errMsg string
reader *strings.Reader
}
func (m *mockReadCloser) Read(p []byte) (n int, err error) {
if m.errMsg != "" {
return 0, errors.New(m.errMsg)
}
if m.reader == nil {
m.reader = strings.NewReader(m.result)
}
return m.reader.Read(p)
}
func (m *mockReadCloser) Close() error {
return nil
}
type mockHTTPClient struct {
resp *http.Response
err error
lastFeedURL string
lastOptions *model.FeedRequestOptions
}
func (m *mockHTTPClient) Get(ctx context.Context, link string, options model.FeedRequestOptions) (*http.Response, error) {
// Store the last feed URL and options for assertions.
m.lastFeedURL = link
m.lastOptions = &options
if m.err != nil {
return nil, m.err
}
return m.resp, nil
}
func TestFeedClientFetchTitle(t *testing.T) {
for _, tt := range []struct {
description string
feedURL string
options model.FeedRequestOptions
httpRespBody string
httpStatusCode int
httpErr error
httpBodyReadErrMsg string
expectedTitle string
expectedErrMsg string
}{
{
description: "fetch title succeeds when HTTP request and RSS parse succeed",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
Test Feed Title
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedTitle: "Test Feed Title",
expectedErrMsg: "",
},
{
description: "fetch title succeeds with default behavior when options are nil",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
Test Feed Title
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedTitle: "Test Feed Title",
expectedErrMsg: "",
},
{
description: "fetch title succeeds when using configured proxy server",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{
ReqProxy: func() *string { s := "http://proxy.example.com:8080"; return &s }(),
},
httpRespBody: `
Test Feed Title via Proxy
-
Test Item via Proxy
https://example.com/proxy-item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedTitle: "Test Feed Title via Proxy",
expectedErrMsg: "",
},
{
description: "fetch title fails when HTTP request returns connection error",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: "",
httpStatusCode: 0, // No status code since request errors
httpErr: errors.New("connection refused"),
httpBodyReadErrMsg: "",
expectedTitle: "",
expectedErrMsg: "connection refused",
},
{
description: "fetch title fails when HTTP response has non-200 status code",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: "",
httpStatusCode: http.StatusNotFound,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedTitle: "",
expectedErrMsg: "got status code 404",
},
{
description: "fetch title fails when HTTP response body cannot be read",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: "",
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "mock body read error",
expectedTitle: "",
expectedErrMsg: "mock body read error",
},
{
description: "fetch title fails when RSS content cannot be parsed",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
This is not a valid RSS feed
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedTitle: "",
expectedErrMsg: "Failed to detect feed type",
},
{
description: "fetch title returns empty string when feed has no title",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedTitle: "",
expectedErrMsg: "",
},
} {
t.Run(tt.description, func(t *testing.T) {
body := &mockReadCloser{
result: tt.httpRespBody,
errMsg: tt.httpBodyReadErrMsg,
}
httpClient := &mockHTTPClient{
resp: &http.Response{
StatusCode: tt.httpStatusCode,
Status: http.StatusText(tt.httpStatusCode),
Body: body,
},
err: tt.httpErr,
}
actualTitle, actualErr := client.NewFeedClientWithRequestFn(httpClient.Get).FetchTitle(context.Background(), tt.feedURL, tt.options)
if tt.expectedErrMsg != "" {
require.Error(t, actualErr)
require.Contains(t, actualErr.Error(), tt.expectedErrMsg)
} else {
require.NoError(t, actualErr)
}
assert.Equal(t, tt.expectedTitle, actualTitle)
assert.Equal(t, tt.feedURL, httpClient.lastFeedURL, "Incorrect feed URL used")
assert.Equal(t, tt.options, *httpClient.lastOptions, "Incorrect HTTP request options")
})
}
}
func TestFeedClientFetchDeclaredLink(t *testing.T) {
for _, tt := range []struct {
description string
httpRespBody string
httpStatusCode int
httpErr error
httpBodyReadErrMsg string
expectedLink string
expectedErrMsg string
}{
{
description: "fetch declared link succeeds when HTTP request and RSS parse succeed",
httpRespBody: `
Test Feed Title
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedLink: "https://example.com/declared-feed.xml",
expectedErrMsg: "",
},
{
description: "fetch declared link from RSS 2.0 feed with standard link element",
httpRespBody: `
Test Feed Title
http://rss2.example.com/
A dummy RSS news feed.
-
Test Item
http://rss2.example.com/article1
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedLink: "http://rss2.example.com/",
expectedErrMsg: "",
},
{
description: "fetch declared link returns empty string when feed has no link",
httpRespBody: `
Test Feed Title
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedLink: "",
expectedErrMsg: "",
},
{
description: "fetch declared link fails when HTTP request returns connection error",
httpRespBody: "",
httpStatusCode: 0, // No status code since request errors
httpErr: errors.New("dummy connection refused error"),
httpBodyReadErrMsg: "",
expectedLink: "",
expectedErrMsg: "dummy connection refused error",
},
{
description: "fetch declared link fails when HTTP response body cannot be read",
httpRespBody: "",
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "mock body read error",
expectedLink: "",
expectedErrMsg: "mock body read error",
},
} {
t.Run(tt.description, func(t *testing.T) {
body := &mockReadCloser{
result: tt.httpRespBody,
errMsg: tt.httpBodyReadErrMsg,
}
httpClient := &mockHTTPClient{
resp: &http.Response{
StatusCode: tt.httpStatusCode,
Status: http.StatusText(tt.httpStatusCode),
Body: body,
},
err: tt.httpErr,
}
// The feedURL and options don't matter in the test because we're mocking
// out the HTTP functionality, but we just need to make sure the HTTP
// client receives the right values.
feedURL := "https://dummy.example.com/rss"
options := model.FeedRequestOptions{}
actualLink, actualErr := client.NewFeedClientWithRequestFn(httpClient.Get).FetchDeclaredLink(context.Background(), feedURL, options)
if tt.expectedErrMsg != "" {
require.Error(t, actualErr)
require.Contains(t, actualErr.Error(), tt.expectedErrMsg)
} else {
require.NoError(t, actualErr)
}
assert.Equal(t, tt.expectedLink, actualLink)
assert.Equal(t, feedURL, httpClient.lastFeedURL, "Incorrect feed URL used")
assert.Equal(t, options, *httpClient.lastOptions, "Incorrect HTTP request options")
})
}
}
func TestFeedClientFetchItems(t *testing.T) {
for _, tt := range []struct {
description string
feedURL string
options model.FeedRequestOptions
httpRespBody string
httpStatusCode int
httpErr error
httpBodyReadErrMsg string
expectedResult client.FetchItemsResult
expectedErrMsg string
}{
{
description: "fetch succeeds with no LastBuild when feed has no updated time",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
Test Feed
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{
LastBuild: nil, // UpdatedParsed is nil in this test case
Items: []*model.Item{
{
Title: ptr.To("Test Item"),
Link: ptr.To("https://example.com/item"),
},
},
},
expectedErrMsg: "",
},
{
description: "fetch succeeds and populates LastBuild from RSS lastBuildDate",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
Test Feed
2025-01-01T12:00:00Z
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{
LastBuild: mustParseTime("2025-01-01T12:00:00Z"),
Items: []*model.Item{
{
Title: ptr.To("Test Item"),
Link: ptr.To("https://example.com/item"),
},
},
},
expectedErrMsg: "",
},
{
description: "fetch succeeds and populates LastBuild from Atom updated",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
Test Feed
2025-02-15T15:30:00Z
Test Item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{
LastBuild: mustParseTime("2025-02-15T15:30:00Z"),
Items: []*model.Item{
{
Title: ptr.To("Test Item"),
Link: ptr.To("https://example.com/item"),
},
},
},
expectedErrMsg: "",
},
{
description: "fetch succeeds with different timezone in lastBuildDate",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
Test Feed
2025-01-01T07:00:00-05:00
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{
LastBuild: mustParseTime("2025-01-01T12:00:00Z"), // Same time as UTC
Items: []*model.Item{
{
Title: ptr.To("Test Item"),
Link: ptr.To("https://example.com/item"),
},
},
},
expectedErrMsg: "",
},
{
description: "fetch succeeds with non-standard time format",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
Test Feed
Wed, 01 Jan 2025 12:00:00 GMT
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{
LastBuild: mustParseTime("2025-01-01T12:00:00Z"), // Use UTC format since gofeed normalizes to UTC
Items: []*model.Item{
{
Title: ptr.To("Test Item"),
Link: ptr.To("https://example.com/item"),
},
},
},
expectedErrMsg: "",
},
{
description: "fetch succeeds with default behavior when options are nil",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
Test Feed
2025-01-01T12:00:00Z
-
Test Item
https://example.com/item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{
LastBuild: mustParseTime("2025-01-01T12:00:00Z"),
Items: []*model.Item{
{
Title: ptr.To("Test Item"),
Link: ptr.To("https://example.com/item"),
},
},
},
expectedErrMsg: "",
},
{
description: "fetch succeeds when using configured proxy server",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{
ReqProxy: func() *string { s := "http://proxy.example.com:8080"; return &s }(),
},
httpRespBody: `
Test Feed via Proxy
2025-01-01T12:00:00Z
-
Test Item via Proxy
https://example.com/proxy-item
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{
LastBuild: mustParseTime("2025-01-01T12:00:00Z"),
Items: []*model.Item{
{
Title: ptr.To("Test Item via Proxy"),
Link: ptr.To("https://example.com/proxy-item"),
},
},
},
expectedErrMsg: "",
},
{
description: "fetch fails when HTTP request returns connection error",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: "",
httpStatusCode: 0, // No status code since request errors
httpErr: errors.New("connection refused"),
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{},
expectedErrMsg: "connection refused",
},
{
description: "fetch fails when HTTP response has non-200 status code",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: "",
httpStatusCode: http.StatusNotFound,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{},
expectedErrMsg: "got status code 404",
},
{
description: "fetch fails when HTTP response body cannot be read",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: "",
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "mock body read error",
expectedResult: client.FetchItemsResult{},
expectedErrMsg: "mock body read error",
},
{
description: "fetch fails when RSS content cannot be parsed",
feedURL: "https://example.com/feed.xml",
options: model.FeedRequestOptions{},
httpRespBody: `
This is not a valid RSS feed
`,
httpStatusCode: http.StatusOK,
httpErr: nil,
httpBodyReadErrMsg: "",
expectedResult: client.FetchItemsResult{},
expectedErrMsg: "Failed to detect feed type",
},
} {
t.Run(tt.description, func(t *testing.T) {
body := &mockReadCloser{
result: tt.httpRespBody,
errMsg: tt.httpBodyReadErrMsg,
}
httpClient := &mockHTTPClient{
resp: &http.Response{
StatusCode: tt.httpStatusCode,
Status: http.StatusText(tt.httpStatusCode),
Body: body,
},
err: tt.httpErr,
}
actualResult, actualErr := client.NewFeedClientWithRequestFn(httpClient.Get).FetchItems(context.Background(), tt.feedURL, tt.options)
if tt.expectedErrMsg != "" {
require.Error(t, actualErr)
require.Contains(t, actualErr.Error(), tt.expectedErrMsg)
} else {
require.NoError(t, actualErr)
}
if tt.expectedResult.LastBuild != nil {
require.NotNil(t, actualResult.LastBuild, "LastBuild should not be nil")
assert.Equal(t, *tt.expectedResult.LastBuild, *actualResult.LastBuild, "LastBuild time doesn't match")
} else {
assert.Nil(t, actualResult.LastBuild, "LastBuild should be nil")
}
assert.Equal(t, len(tt.expectedResult.Items), len(actualResult.Items))
if len(tt.expectedResult.Items) > 0 {
for i, expectedItem := range tt.expectedResult.Items {
if i < len(actualResult.Items) {
actualItem := actualResult.Items[i]
if expectedItem.Title != nil {
assert.Equal(t, *expectedItem.Title, *actualItem.Title)
}
if expectedItem.Link != nil {
assert.Equal(t, *expectedItem.Link, *actualItem.Link)
}
}
}
}
assert.Equal(t, tt.feedURL, httpClient.lastFeedURL, "Incorrect feed URL used")
assert.Equal(t, tt.options, *httpClient.lastOptions, "Incorrect HTTP request options")
})
}
}
// Helper function to parse ISO8601 string to time.Time.
func mustParseTime(iso8601 string) *time.Time {
t, err := time.Parse(time.RFC3339, iso8601)
if err != nil {
panic(err)
}
return &t
}