新增 LoadFeedWithOptions(ctx, feedURL, FeedOptions{Proxy, Timeout, UserAgent}),
LoadFeed / LoadFeedContext 签名一字不改、降级为它的包装——库自身 main.go 与四个
既有测试的 5 处调用因此零改动。
动机:调用方要把「回源 feed」和「可达性探测」用同一个 UA 发出去,两者视角不一致
则打标不可信;而此前 UA 写死在函数体里、无从覆盖。
空 UA 不原样发出而是回落默认:gofeed 无条件 Header.Set("User-Agent", …),net/http
见到空值会把整条头省掉,而不发 UA 比发任何 UA 都更容易被拒(实测有源站对无 UA
请求直接 403)。
默认值保持原样并提为导出常量 DefaultUserAgent。顺带把它的 doc comment 从「不知道
当年为什么设成 Chrome」改成实测得出的可检验规则:约束是「不得是 HTTP 库出厂 UA、
不得为空」,而非「必须是浏览器」——feed.xyzfm.space 对 "Gofeed/1.0"(ec79b8b 之前
本函数实际发出的值)与 "Go-http-client/1.1" 返回 403 且响应头写明
X-Tengine-Error: denied by UA ACL = blacklist,而 Chrome、txpodcast/1.0、
podcast-search-go/0.1 一律放行。
补 UA 断言测试(此前四个测试从未断言过 UA):显式 UA 逐字节发出、空串回落默认、
旧签名仍发默认、timeout<=0 仍回落 30s。已验证注入回归时会红。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
153 lines
4.8 KiB
Go
153 lines
4.8 KiB
Go
package model
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
)
|
||
|
||
func TestLoadFeed_Success(t *testing.T) {
|
||
// Mock RSS feed
|
||
rssFeed := `
|
||
<rss version="2.0">
|
||
<channel>
|
||
<title>Test Podcast</title>
|
||
<description>This is a test podcast</description>
|
||
<link>http://example.com</link>
|
||
<image>
|
||
<url>http://example.com/image.jpg</url>
|
||
</image>
|
||
<copyright>Test Copyright</copyright>
|
||
<language>en</language>
|
||
<item>
|
||
<title>Episode 1</title>
|
||
<link>http://example.com/episode1</link>
|
||
<description>Episode 1 description</description>
|
||
</item>
|
||
</channel>
|
||
</rss>
|
||
`
|
||
|
||
// Create a test server
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write([]byte(rssFeed))
|
||
}))
|
||
defer server.Close()
|
||
|
||
// Call LoadFeed
|
||
podcast, err := LoadFeed(server.URL, "", 10*time.Second)
|
||
|
||
// Assertions
|
||
assert.NoError(t, err)
|
||
assert.NotNil(t, podcast)
|
||
assert.Equal(t, "Test Podcast", podcast.Title)
|
||
assert.Equal(t, "This is a test podcast", podcast.Description)
|
||
assert.Equal(t, "http://example.com", podcast.Link)
|
||
assert.Equal(t, "http://example.com/image.jpg", podcast.Image)
|
||
assert.Equal(t, "Test Copyright", podcast.Copyright)
|
||
assert.Equal(t, "en", podcast.Language)
|
||
assert.Len(t, podcast.Episodes, 1)
|
||
assert.Equal(t, "Episode 1", podcast.Episodes[0].Title)
|
||
}
|
||
|
||
func TestLoadFeed_Timeout(t *testing.T) {
|
||
// Create a test server that delays response
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
time.Sleep(2 * time.Second)
|
||
w.Write([]byte("<rss></rss>"))
|
||
}))
|
||
defer server.Close()
|
||
|
||
// Call LoadFeed with a short timeout
|
||
_, err := LoadFeed(server.URL, "", 1*time.Second)
|
||
|
||
// Assertions
|
||
assert.Error(t, err)
|
||
assert.Contains(t, err.Error(), "context deadline exceeded")
|
||
}
|
||
|
||
func TestLoadFeed_InvalidURL(t *testing.T) {
|
||
// Call LoadFeed with an invalid URL
|
||
_, err := LoadFeed("http://invalid-url", "", 10*time.Second)
|
||
|
||
// Assertions
|
||
assert.Error(t, err)
|
||
assert.Contains(t, err.Error(), "解析播客订阅失败")
|
||
}
|
||
|
||
func TestLoadFeed_EmptyFeed(t *testing.T) {
|
||
// Create a test server with an empty feed
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write([]byte(""))
|
||
}))
|
||
defer server.Close()
|
||
|
||
// Call LoadFeed
|
||
_, err := LoadFeed(server.URL, "", 10*time.Second)
|
||
|
||
// Assertions
|
||
assert.Error(t, err)
|
||
assert.Contains(t, err.Error(), "解析播客订阅失败")
|
||
}
|
||
|
||
// UA 是这个库唯一会影响「能不能抓到」的可配项:实测有源站按 UA 黑名单直接 403
|
||
// (feed.xyzfm.space 对 gofeed 出厂的 "Gofeed/1.0" 返回 denied by UA ACL = blacklist),
|
||
// 且无 UA 的请求比任何 UA 都更容易被拒。故这三条都必须钉死在测试里。
|
||
func TestLoadFeedWithOptions_UserAgent(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
give string
|
||
want string
|
||
}{
|
||
{"显式指定的 UA 逐字节发出", "txpodcast/1.0", "txpodcast/1.0"},
|
||
{"空串回落默认而不是发空头", "", DefaultUserAgent},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
// buffered chan 而非裸变量:handler 在另一个 goroutine 里写,-race 会告警。
|
||
got := make(chan string, 1)
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
got <- r.Header.Get("User-Agent")
|
||
_, _ = w.Write([]byte(`<rss version="2.0"><channel><title>t</title></channel></rss>`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
_, err := LoadFeedWithOptions(context.Background(), srv.URL,
|
||
FeedOptions{UserAgent: tc.give, Timeout: 10 * time.Second})
|
||
assert.NoError(t, err)
|
||
assert.Equal(t, tc.want, <-got)
|
||
})
|
||
}
|
||
}
|
||
|
||
// 旧签名必须继续发默认 UA:它是 LoadFeedWithOptions 的包装,委托时若漏传就会退化成空头。
|
||
func TestLoadFeedContext_KeepsDefaultUserAgent(t *testing.T) {
|
||
got := make(chan string, 1)
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
got <- r.Header.Get("User-Agent")
|
||
_, _ = w.Write([]byte(`<rss version="2.0"><channel><title>t</title></channel></rss>`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
_, err := LoadFeedContext(context.Background(), srv.URL, "", 10*time.Second)
|
||
assert.NoError(t, err)
|
||
assert.Equal(t, DefaultUserAgent, <-got)
|
||
}
|
||
|
||
// timeout <= 0 回落 30 秒的既有语义不能因为改走 FeedOptions 而丢失。
|
||
func TestLoadFeedWithOptions_ZeroTimeoutFallsBack(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`<rss version="2.0"><channel><title>t</title></channel></rss>`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
// 0 若被原样当成超时,请求会立刻 context deadline exceeded。
|
||
_, err := LoadFeedWithOptions(context.Background(), srv.URL, FeedOptions{})
|
||
assert.NoError(t, err)
|
||
}
|