Files
zenfeed/pkg/scrape/scraper/scraper_test.go
2025-07-09 17:28:26 +08:00

295 lines
8.7 KiB
Go

// Copyright (C) 2025 wangyusong
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package scraper
import (
"testing"
"time"
. "github.com/onsi/gomega"
"github.com/glidea/zenfeed/pkg/test"
timeutil "github.com/glidea/zenfeed/pkg/util/time"
)
func TestConfig_Validate(t *testing.T) {
RegisterTestingT(t)
// --- Test types ---
type givenDetail struct {
config *Config
}
type whenDetail struct{} // Validation is the action
type thenExpected struct {
expectedConfig *Config // Expected state after validation
isErr bool
wantErrMsg string
}
// --- Test cases ---
tests := []test.Case[givenDetail, whenDetail, thenExpected]{
{
Scenario: "Default values",
Given: "a config with zero values for Past and Interval and non-empty Name",
When: "validating the config",
Then: "should set default Past and Interval, and no error",
GivenDetail: givenDetail{
config: &Config{Name: "test"}, // Name is required
},
WhenDetail: whenDetail{},
ThenExpected: thenExpected{
expectedConfig: &Config{
Name: "test",
Past: timeutil.Day, // Default Past
Interval: time.Hour, // Default/Minimum Interval
},
isErr: false,
},
},
{
Scenario: "Past exceeds maximum",
Given: "a config with Past exceeding the maximum limit",
When: "validating the config",
Then: "should cap Past to the maximum value",
GivenDetail: givenDetail{
config: &Config{Name: "test", Past: maxPast + time.Hour},
},
WhenDetail: whenDetail{},
ThenExpected: thenExpected{
expectedConfig: &Config{
Name: "test",
Past: maxPast, // Capped Past
Interval: time.Hour, // Default Interval
},
isErr: false,
},
},
{
Scenario: "Interval below minimum",
Given: "a config with Interval below the minimum limit",
When: "validating the config",
Then: "should set Interval to the minimum value",
GivenDetail: givenDetail{
config: &Config{Name: "test", Interval: time.Second},
},
WhenDetail: whenDetail{},
ThenExpected: thenExpected{
expectedConfig: &Config{
Name: "test",
Past: timeutil.Day, // Default Past
Interval: 10 * time.Minute, // Minimum Interval
},
isErr: false,
},
},
{
Scenario: "Valid values",
Given: "a config with valid Past and Interval",
When: "validating the config",
Then: "should keep the original values",
GivenDetail: givenDetail{
config: &Config{
Name: "test",
Past: 4 * time.Hour,
Interval: 30 * time.Minute,
},
},
WhenDetail: whenDetail{},
ThenExpected: thenExpected{
expectedConfig: &Config{
Name: "test",
Past: 4 * time.Hour,
Interval: 30 * time.Minute,
},
isErr: false,
},
},
{
Scenario: "Missing Name",
Given: "a config with an empty Name",
When: "validating the config",
Then: "should return an error",
GivenDetail: givenDetail{
config: &Config{}, // Empty Name
},
WhenDetail: whenDetail{},
ThenExpected: thenExpected{
isErr: true,
wantErrMsg: "name cannot be empty",
},
},
}
// --- Run tests ---
for _, tt := range tests {
t.Run(tt.Scenario, func(t *testing.T) {
// --- Given ---
config := tt.GivenDetail.config // Use the config from the test case
// --- When ---
err := config.Validate()
// --- Then ---
if tt.ThenExpected.isErr {
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.wantErrMsg))
} else {
Expect(err).NotTo(HaveOccurred())
// Compare the validated config with the expected one
Expect(config).To(Equal(tt.ThenExpected.expectedConfig))
}
})
}
}
func TestNew(t *testing.T) {
RegisterTestingT(t)
// --- Test types ---
type givenDetail struct {
instance string
config *Config
dependencies Dependencies // Keep dependencies empty for now, focus on config validation
}
type whenDetail struct{} // Creation is the action
type thenExpected struct {
isErr bool
wantErrMsg string
validateFunc func(t *testing.T, s Scraper) // Optional validation
}
// --- Test cases ---
validRSSConfig := &ScrapeSourceRSS{URL: "http://valid.com/feed"}
validBaseConfig := &Config{
Name: "test-scraper",
Interval: 15 * time.Minute, // Valid interval
RSS: validRSSConfig, // Need a valid source for newReader
}
tests := []test.Case[givenDetail, whenDetail, thenExpected]{
{
Scenario: "Valid Configuration",
Given: "a valid config and dependencies",
When: "creating a new scraper",
Then: "should create scraper successfully",
GivenDetail: givenDetail{
instance: "scraper-1",
config: validBaseConfig,
dependencies: Dependencies{}, // Empty deps are okay for New itself
},
WhenDetail: whenDetail{},
ThenExpected: thenExpected{
isErr: false,
validateFunc: func(t *testing.T, s Scraper) {
Expect(s).NotTo(BeNil())
Expect(s.Name()).To(Equal("Scraper")) // From Base component
Expect(s.Instance()).To(Equal("scraper-1"))
Expect(s.Config()).To(Equal(validBaseConfig)) // Check if config is stored
// Check internal state if needed (e.g., source type)
concreteScraper, ok := s.(*scraper)
Expect(ok).To(BeTrue())
Expect(concreteScraper.source).NotTo(BeNil())
_, isRSSReader := concreteScraper.source.(*rssReader)
Expect(isRSSReader).To(BeTrue())
},
},
},
{
Scenario: "Invalid Configuration - Validation Fail",
Given: "a config that fails validation (e.g., missing name)",
When: "creating a new scraper",
Then: "should return a validation error",
GivenDetail: givenDetail{
instance: "scraper-invalid",
config: &Config{ // Missing Name, invalid interval
Interval: time.Second,
RSS: validRSSConfig,
},
dependencies: Dependencies{},
},
WhenDetail: whenDetail{},
ThenExpected: thenExpected{
isErr: true,
wantErrMsg: "invalid scraper config: name cannot be empty", // Specific validation error
},
},
{
Scenario: "Invalid Configuration - Source Creation Fail",
Given: "a config that passes validation but has invalid source details",
When: "creating a new scraper",
Then: "should return an error from source creation",
GivenDetail: givenDetail{
instance: "scraper-bad-source",
config: &Config{
Name: "test-bad-source",
Interval: 15 * time.Minute,
RSS: &ScrapeSourceRSS{URL: "invalid-url-format"}, // Invalid RSS URL
},
dependencies: Dependencies{},
},
WhenDetail: whenDetail{},
ThenExpected: thenExpected{
isErr: true,
wantErrMsg: "invalid RSS config: URL must be a valid HTTP/HTTPS URL", // Error from newRSSReader via newReader
},
},
{
Scenario: "Invalid Configuration - No Source Configured",
Given: "a config that passes validation but lacks any source config (RSS is nil)",
When: "creating a new scraper",
Then: "should return an error indicating unsupported source",
GivenDetail: givenDetail{
instance: "scraper-no-source",
config: &Config{
Name: "test-no-source",
Interval: 15 * time.Minute,
RSS: nil, // No source configured
},
dependencies: Dependencies{},
},
WhenDetail: whenDetail{},
ThenExpected: thenExpected{
isErr: true,
wantErrMsg: "source not supported", // Error from newReader
},
},
}
// --- Run tests ---
factory := NewFactory() // Use the real factory
for _, tt := range tests {
t.Run(tt.Scenario, func(t *testing.T) {
// --- Given & When ---
s, err := factory.New(tt.GivenDetail.instance, tt.GivenDetail.config, tt.GivenDetail.dependencies)
// --- Then ---
if tt.ThenExpected.isErr {
Expect(err).To(HaveOccurred())
// Use MatchError for wrapped errors if necessary, but ContainSubstring is often sufficient
Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.wantErrMsg))
Expect(s).To(BeNil())
} else {
Expect(err).NotTo(HaveOccurred())
Expect(s).NotTo(BeNil())
if tt.ThenExpected.validateFunc != nil {
tt.ThenExpected.validateFunc(t, s)
}
}
})
}
}