1320 lines
37 KiB
Go
1320 lines
37 KiB
Go
//go:build linux || freebsd
|
|
// +build linux freebsd
|
|
|
|
package recursive
|
|
|
|
import (
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ============================================================
|
|
// Filter tests
|
|
// ============================================================
|
|
|
|
func TestNewURLFilter(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/docs/")
|
|
cfg := URLFilterConfig{
|
|
MaxDepth: 3,
|
|
FollowExternal: false,
|
|
ExcludePatterns: []string{`\.pdf$`, `\.zip$`},
|
|
IncludePatterns: []string{`\.html$`},
|
|
}
|
|
filter := NewURLFilter(baseURL, cfg)
|
|
|
|
if filter == nil {
|
|
t.Fatal("NewURLFilter returned nil")
|
|
}
|
|
if filter.baseURL.String() != "https://example.com/docs/" {
|
|
t.Errorf("expected baseURL https://example.com/docs/, got %s", filter.baseURL.String())
|
|
}
|
|
if filter.maxDepth != 3 {
|
|
t.Errorf("expected maxDepth 3, got %d", filter.maxDepth)
|
|
}
|
|
if filter.followExternal != false {
|
|
t.Errorf("expected followExternal false, got %v", filter.followExternal)
|
|
}
|
|
if len(filter.excludePatterns) != 2 {
|
|
t.Errorf("expected 2 exclude patterns, got %d", len(filter.excludePatterns))
|
|
}
|
|
if len(filter.includePatterns) != 1 {
|
|
t.Errorf("expected 1 include pattern, got %d", len(filter.includePatterns))
|
|
}
|
|
}
|
|
|
|
func TestNewURLFilterWithPatterns(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("empty patterns", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{})
|
|
if len(filter.excludePatterns) != 0 {
|
|
t.Errorf("expected 0 exclude patterns, got %d", len(filter.excludePatterns))
|
|
}
|
|
if len(filter.includePatterns) != 0 {
|
|
t.Errorf("expected 0 include patterns, got %d", len(filter.includePatterns))
|
|
}
|
|
})
|
|
|
|
t.Run("invalid patterns are skipped silently", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
ExcludePatterns: []string{`[invalid`},
|
|
IncludePatterns: []string{`[also invalid`},
|
|
})
|
|
if len(filter.excludePatterns) != 0 {
|
|
t.Errorf("invalid exclude pattern should be skipped, got %d patterns", len(filter.excludePatterns))
|
|
}
|
|
if len(filter.includePatterns) != 0 {
|
|
t.Errorf("invalid include pattern should be skipped, got %d patterns", len(filter.includePatterns))
|
|
}
|
|
})
|
|
|
|
t.Run("mixed valid and invalid patterns", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
ExcludePatterns: []string{`\.pdf$`, `[bad`, `\.zip$`},
|
|
IncludePatterns: []string{`[bad`, `\.html$`},
|
|
})
|
|
if len(filter.excludePatterns) != 2 {
|
|
t.Errorf("expected 2 valid exclude patterns, got %d", len(filter.excludePatterns))
|
|
}
|
|
if len(filter.includePatterns) != 1 {
|
|
t.Errorf("expected 1 valid include pattern, got %d", len(filter.includePatterns))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestShouldDownloadBasic(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
MaxDepth: 5,
|
|
})
|
|
|
|
t.Run("http scheme allowed", func(t *testing.T) {
|
|
u, _ := url.Parse("http://example.com/page")
|
|
if !filter.ShouldDownload(u, 0) {
|
|
t.Error("http URL should be allowed")
|
|
}
|
|
})
|
|
|
|
t.Run("https scheme allowed", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/page")
|
|
if !filter.ShouldDownload(u, 0) {
|
|
t.Error("https URL should be allowed")
|
|
}
|
|
})
|
|
|
|
t.Run("same host allowed default", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/other")
|
|
if !filter.ShouldDownload(u, 0) {
|
|
t.Error("same host should be allowed")
|
|
}
|
|
})
|
|
|
|
t.Run("nil URL panics", func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("ShouldDownload with nil URL should panic")
|
|
}
|
|
}()
|
|
filter.ShouldDownload(nil, 0)
|
|
})
|
|
}
|
|
|
|
func TestShouldDownloadDepth(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("within depth", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{MaxDepth: 3})
|
|
u, _ := url.Parse("https://example.com/page")
|
|
if !filter.ShouldDownload(u, 2) {
|
|
t.Error("depth 2 should be allowed with maxDepth 3")
|
|
}
|
|
})
|
|
|
|
t.Run("exactly at depth boundary", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{MaxDepth: 3})
|
|
u, _ := url.Parse("https://example.com/page")
|
|
if !filter.ShouldDownload(u, 3) {
|
|
t.Error("depth 3 should be allowed with maxDepth 3 (exact boundary)")
|
|
}
|
|
})
|
|
|
|
t.Run("exceeds depth", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{MaxDepth: 3})
|
|
u, _ := url.Parse("https://example.com/page")
|
|
if filter.ShouldDownload(u, 4) {
|
|
t.Error("depth 4 should NOT be allowed with maxDepth 3")
|
|
}
|
|
})
|
|
|
|
t.Run("zero maxDepth means only depth 0", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{MaxDepth: 0})
|
|
u, _ := url.Parse("https://example.com/page")
|
|
if !filter.ShouldDownload(u, 0) {
|
|
t.Error("depth 0 should be allowed with maxDepth 0")
|
|
}
|
|
if filter.ShouldDownload(u, 1) {
|
|
t.Error("depth 1 should NOT be allowed with maxDepth 0")
|
|
}
|
|
})
|
|
|
|
t.Run("negative depth", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{MaxDepth: 0})
|
|
u, _ := url.Parse("https://example.com/page")
|
|
if !filter.ShouldDownload(u, -1) {
|
|
t.Error("negative depth should be allowed with maxDepth 0 (depth check is currentDepth > maxDepth, and -1 > 0 is false)")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestShouldDownloadScheme(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{MaxDepth: 5})
|
|
|
|
tests := []struct {
|
|
urlStr string
|
|
allow bool
|
|
}{
|
|
{"http://example.com/page", true},
|
|
{"https://example.com/page", true},
|
|
{"ftp://example.com/file", false},
|
|
{"file:///tmp/doc.txt", false},
|
|
{"data:text/html,Hello", false},
|
|
{"javascript:void(0)", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
u, err := url.Parse(tt.urlStr)
|
|
if err != nil {
|
|
t.Errorf("failed to parse URL %s: %v", tt.urlStr, err)
|
|
continue
|
|
}
|
|
got := filter.ShouldDownload(u, 0)
|
|
if got != tt.allow {
|
|
t.Errorf("ShouldDownload(%s) = %v, want %v", tt.urlStr, got, tt.allow)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShouldDownloadExternal(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("external blocked by default", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
MaxDepth: 5,
|
|
FollowExternal: false,
|
|
})
|
|
u, _ := url.Parse("https://other.com/page")
|
|
if filter.ShouldDownload(u, 0) {
|
|
t.Error("external URL should be blocked when followExternal is false")
|
|
}
|
|
})
|
|
|
|
t.Run("external allowed when followExternal is true", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
MaxDepth: 5,
|
|
FollowExternal: true,
|
|
})
|
|
u, _ := url.Parse("https://other.com/page")
|
|
if !filter.ShouldDownload(u, 0) {
|
|
t.Error("external URL should be allowed when followExternal is true")
|
|
}
|
|
})
|
|
|
|
t.Run("same host always allowed", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
MaxDepth: 5,
|
|
FollowExternal: false,
|
|
})
|
|
u, _ := url.Parse("https://example.com/page")
|
|
if !filter.ShouldDownload(u, 0) {
|
|
t.Error("same host URL should always be allowed")
|
|
}
|
|
})
|
|
|
|
t.Run("subdomain is considered external", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
MaxDepth: 5,
|
|
FollowExternal: false,
|
|
})
|
|
u, _ := url.Parse("https://sub.example.com/page")
|
|
if filter.ShouldDownload(u, 0) {
|
|
t.Error("subdomain should be considered external when followExternal is false")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestShouldDownloadExcludePattern(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
MaxDepth: 5,
|
|
ExcludePatterns: []string{`\.pdf$`, `\.zip$`, `/api/`},
|
|
})
|
|
|
|
tests := []struct {
|
|
urlStr string
|
|
allow bool
|
|
}{
|
|
{"https://example.com/doc.pdf", false},
|
|
{"https://example.com/archive.zip", false},
|
|
{"https://example.com/api/users", false},
|
|
{"https://example.com/page.html", true},
|
|
{"https://example.com/image.jpg", true},
|
|
{"https://example.com/style.css", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
u, _ := url.Parse(tt.urlStr)
|
|
got := filter.ShouldDownload(u, 0)
|
|
if got != tt.allow {
|
|
t.Errorf("ShouldDownload(%s) = %v, want %v", tt.urlStr, got, tt.allow)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShouldDownloadIncludePattern(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("include only .html and .css", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
MaxDepth: 5,
|
|
IncludePatterns: []string{`\.html$`, `\.css$`},
|
|
})
|
|
|
|
tests := []struct {
|
|
urlStr string
|
|
allow bool
|
|
}{
|
|
{"https://example.com/page.html", true},
|
|
{"https://example.com/style.css", true},
|
|
{"https://example.com/image.jpg", false},
|
|
{"https://example.com/doc.pdf", false},
|
|
{"https://example.com/script.js", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
u, _ := url.Parse(tt.urlStr)
|
|
got := filter.ShouldDownload(u, 0)
|
|
if got != tt.allow {
|
|
t.Errorf("ShouldDownload(%s) = %v, want %v", tt.urlStr, got, tt.allow)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("include pattern with depth check", func(t *testing.T) {
|
|
filter := NewURLFilter(baseURL, URLFilterConfig{
|
|
MaxDepth: 2,
|
|
IncludePatterns: []string{`\.html$`},
|
|
})
|
|
|
|
u, _ := url.Parse("https://example.com/page.html")
|
|
if filter.ShouldDownload(u, 3) {
|
|
t.Error("include pattern should not override depth limit")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIsSameDomain(t *testing.T) {
|
|
base, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("same host", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/page")
|
|
if !IsSameDomain(base, target) {
|
|
t.Error("same host should return true")
|
|
}
|
|
})
|
|
|
|
t.Run("different host", func(t *testing.T) {
|
|
target, _ := url.Parse("https://other.com/page")
|
|
if IsSameDomain(base, target) {
|
|
t.Error("different host should return false")
|
|
}
|
|
})
|
|
|
|
t.Run("subdomain is different", func(t *testing.T) {
|
|
target, _ := url.Parse("https://sub.example.com/page")
|
|
if IsSameDomain(base, target) {
|
|
t.Error("subdomain should be treated as different domain")
|
|
}
|
|
})
|
|
|
|
t.Run("nil base", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/page")
|
|
if IsSameDomain(nil, target) {
|
|
t.Error("nil base should return false")
|
|
}
|
|
})
|
|
|
|
t.Run("nil target", func(t *testing.T) {
|
|
if IsSameDomain(base, nil) {
|
|
t.Error("nil target should return false")
|
|
}
|
|
})
|
|
|
|
t.Run("both nil", func(t *testing.T) {
|
|
if IsSameDomain(nil, nil) {
|
|
t.Error("both nil should return false")
|
|
}
|
|
})
|
|
|
|
t.Run("same host different scheme", func(t *testing.T) {
|
|
target, _ := url.Parse("http://example.com/page")
|
|
if !IsSameDomain(base, target) {
|
|
t.Error("same host with different scheme should return true")
|
|
}
|
|
})
|
|
|
|
t.Run("same host different port", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com:8080/page")
|
|
if !IsSameDomain(base, target) {
|
|
t.Error("same host with different port should return true")
|
|
}
|
|
})
|
|
|
|
t.Run("www prefix considered different", func(t *testing.T) {
|
|
target, _ := url.Parse("https://www.example.com/page")
|
|
if IsSameDomain(base, target) {
|
|
t.Error("www subdomain should be treated as different domain")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIsSubPath(t *testing.T) {
|
|
base, _ := url.Parse("https://example.com/docs/")
|
|
|
|
t.Run("direct subpath", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/docs/user-guide")
|
|
if !IsSubPath(base, target) {
|
|
t.Error("/docs/user-guide should be subpath of /docs/")
|
|
}
|
|
})
|
|
|
|
t.Run("nested subpath", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/docs/user-guide/chapter1")
|
|
if !IsSubPath(base, target) {
|
|
t.Error("/docs/user-guide/chapter1 should be subpath of /docs/")
|
|
}
|
|
})
|
|
|
|
t.Run("not a subpath", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/other/page")
|
|
if IsSubPath(base, target) {
|
|
t.Error("/other/page should NOT be subpath of /docs/")
|
|
}
|
|
})
|
|
|
|
t.Run("same path", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/docs/")
|
|
if !IsSubPath(base, target) {
|
|
t.Error("same path should be a subpath")
|
|
}
|
|
})
|
|
|
|
t.Run("root base", func(t *testing.T) {
|
|
rootBase, _ := url.Parse("https://example.com/")
|
|
target, _ := url.Parse("https://example.com/anything")
|
|
if !IsSubPath(rootBase, target) {
|
|
t.Error("anything should be subpath of root")
|
|
}
|
|
})
|
|
|
|
t.Run("nil base", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/docs/page")
|
|
if IsSubPath(nil, target) {
|
|
t.Error("nil base should return false")
|
|
}
|
|
})
|
|
|
|
t.Run("nil target", func(t *testing.T) {
|
|
if IsSubPath(base, nil) {
|
|
t.Error("nil target should return false")
|
|
}
|
|
})
|
|
|
|
t.Run("both nil", func(t *testing.T) {
|
|
if IsSubPath(nil, nil) {
|
|
t.Error("both nil should return false")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetLocalPath(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/docs/")
|
|
|
|
t.Run("same host with path", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/docs/user-guide.html")
|
|
path := GetLocalPath(baseURL, target, "/output")
|
|
expected := "/output/docs/user-guide.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("same host no extension", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/docs/about")
|
|
path := GetLocalPath(baseURL, target, "/output")
|
|
expected := "/output/docs/about.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("root path with trailing slash", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/docs/")
|
|
path := GetLocalPath(baseURL, target, "/output")
|
|
expected := "/output/docs/index.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("empty path resolves to index.html", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com")
|
|
path := GetLocalPath(baseURL, target, "/output")
|
|
expected := "/output/index.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetLocalPathSameHost(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("basic path", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/page.html")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/page.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("nested path", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/a/b/c.html")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/a/b/c.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("path with trailing slash", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/blog/")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/blog/index.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("file at root", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/data.json")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/data.json"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetLocalPathDifferentHost(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("different host includes hostname in path", func(t *testing.T) {
|
|
target, _ := url.Parse("https://other.com/page.html")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/other.com/page.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("different host nested path", func(t *testing.T) {
|
|
target, _ := url.Parse("https://other.com/a/b/page.html")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/other.com/a/b/page.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("different host with trailing slash", func(t *testing.T) {
|
|
target, _ := url.Parse("https://other.com/docs/")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/other.com/docs/index.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("different host root path without slash", func(t *testing.T) {
|
|
target, _ := url.Parse("https://other.com")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/other.com/index.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetLocalPathNoExtension(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("path without extension gets .html appended", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/about")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/about.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("nested path without extension", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/docs/about")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/docs/about.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("path with dot in directory name", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/v2.0/docs/about")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/v2.0/docs/about.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
|
|
t.Run("root path with trailing slash and no extension", func(t *testing.T) {
|
|
target, _ := url.Parse("https://example.com/docs/")
|
|
path := GetLocalPath(baseURL, target, "/out")
|
|
expected := "/out/docs/index.html"
|
|
if path != expected {
|
|
t.Errorf("expected %s, got %s", expected, path)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetFilename(t *testing.T) {
|
|
t.Run("normal file", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/page.html")
|
|
if got := GetFilename(u); got != "page.html" {
|
|
t.Errorf("expected page.html, got %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("root path", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/")
|
|
if got := GetFilename(u); got != "index.html" {
|
|
t.Errorf("expected index.html, got %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("empty path", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com")
|
|
if got := GetFilename(u); got != "index.html" {
|
|
t.Errorf("expected index.html, got %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("nested file", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/a/b/file.pdf")
|
|
if got := GetFilename(u); got != "file.pdf" {
|
|
t.Errorf("expected file.pdf, got %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("path ending with slash", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/blog/")
|
|
if got := GetFilename(u); got != "blog" {
|
|
t.Errorf("expected blog, got %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("file with query parameters", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/script.js?ver=2")
|
|
if got := GetFilename(u); got != "script.js" {
|
|
t.Errorf("expected script.js, got %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("deeply nested path", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/a/b/c/d/e/f/image.png")
|
|
if got := GetFilename(u); got != "image.png" {
|
|
t.Errorf("expected image.png, got %s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// Parser tests
|
|
// ============================================================
|
|
|
|
func TestNewLinkExtractor(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
extractor := NewLinkExtractor(baseURL)
|
|
|
|
if extractor == nil {
|
|
t.Fatal("NewLinkExtractor returned nil")
|
|
}
|
|
if extractor.baseURL.String() != "https://example.com/" {
|
|
t.Errorf("expected baseURL https://example.com/, got %s", extractor.baseURL.String())
|
|
}
|
|
if extractor.links == nil {
|
|
t.Error("expected links slice to be initialized")
|
|
}
|
|
if len(extractor.links) != 0 {
|
|
t.Errorf("expected empty links slice, got %d items", len(extractor.links))
|
|
}
|
|
}
|
|
|
|
func TestExtractLinksBasic(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("single anchor tag", func(t *testing.T) {
|
|
html := `<a href="/page.html">Page</a>`
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(links))
|
|
}
|
|
expected := "https://example.com/page.html"
|
|
if links[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, links[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("multiple anchors", func(t *testing.T) {
|
|
html := `
|
|
<a href="/page1.html">Page 1</a>
|
|
<a href="/page2.html">Page 2</a>
|
|
<a href="/page3.html">Page 3</a>
|
|
`
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 3 {
|
|
t.Fatalf("expected 3 links, got %d", len(links))
|
|
}
|
|
})
|
|
|
|
t.Run("absolute URL in href", func(t *testing.T) {
|
|
html := `<a href="https://example.com/absolute.html">Absolute</a>`
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(links))
|
|
}
|
|
expected := "https://example.com/absolute.html"
|
|
if links[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, links[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExtractLinksAllTags(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
html := `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="/style.css">
|
|
</head>
|
|
<body>
|
|
<a href="/page.html">Link</a>
|
|
<img src="/image.png" alt="Image">
|
|
<script src="/script.js"></script>
|
|
<iframe src="/iframe.html"></iframe>
|
|
<video src="/video.mp4"></video>
|
|
<audio src="/audio.mp3"></audio>
|
|
<source src="/source.webm">
|
|
</body>
|
|
</html>
|
|
`
|
|
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// source tag is handled by extractSrc, so all 8 should be extracted
|
|
expectedCount := 8
|
|
if len(links) != expectedCount {
|
|
t.Fatalf("expected %d links, got %d", expectedCount, len(links))
|
|
}
|
|
|
|
// Verify each expected URL is present
|
|
expectedURLs := []string{
|
|
"https://example.com/style.css",
|
|
"https://example.com/page.html",
|
|
"https://example.com/image.png",
|
|
"https://example.com/script.js",
|
|
"https://example.com/iframe.html",
|
|
"https://example.com/video.mp4",
|
|
"https://example.com/audio.mp3",
|
|
"https://example.com/source.webm",
|
|
}
|
|
|
|
for _, expected := range expectedURLs {
|
|
found := false
|
|
for _, link := range links {
|
|
if link.String() == expected {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected URL %s not found in extracted links", expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractLinksSkipInvalid(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
html := `
|
|
<a href="#section">Anchor</a>
|
|
<a href="javascript:void(0)">JS</a>
|
|
<a href="mailto:user@example.com">Email</a>
|
|
<a href="tel:+1234567890">Phone</a>
|
|
<a href="data:text/html,Hello">Data</a>
|
|
<a href="/valid.html">Valid</a>
|
|
<a href="">Empty</a>
|
|
`
|
|
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 valid link, got %d", len(links))
|
|
}
|
|
|
|
expected := "https://example.com/valid.html"
|
|
if links[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, links[0].String())
|
|
}
|
|
}
|
|
|
|
func TestExtractLinksRelativeURLs(t *testing.T) {
|
|
t.Run("relative path with ..", func(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/docs/")
|
|
html := `<a href="../page.html">Up</a>`
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(links))
|
|
}
|
|
expected := "https://example.com/page.html"
|
|
if links[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, links[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("relative path without leading slash", func(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/docs/")
|
|
html := `<a href="guide.html">Guide</a>`
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(links))
|
|
}
|
|
expected := "https://example.com/docs/guide.html"
|
|
if links[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, links[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("root-relative path", func(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/docs/")
|
|
html := `<a href="/about.html">About</a>`
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(links))
|
|
}
|
|
expected := "https://example.com/about.html"
|
|
if links[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, links[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("protocol-relative URL", func(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
html := `<a href="//other.com/page.html">Protocol Relative</a>`
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(links))
|
|
}
|
|
expected := "https://other.com/page.html"
|
|
if links[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, links[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExtractLinksEmpty(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
t.Run("empty byte slice", func(t *testing.T) {
|
|
links, err := ExtractLinks(baseURL, []byte{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 0 {
|
|
t.Errorf("expected 0 links from empty HTML, got %d", len(links))
|
|
}
|
|
})
|
|
|
|
t.Run("nil byte slice", func(t *testing.T) {
|
|
_, err := ExtractLinks(baseURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error from nil input: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("whitespace only", func(t *testing.T) {
|
|
links, err := ExtractLinks(baseURL, []byte(" \n \t "))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 0 {
|
|
t.Errorf("expected 0 links from whitespace, got %d", len(links))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExtractLinksNoLinks(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
html := `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Test</title></head>
|
|
<body>
|
|
<h1>Hello World</h1>
|
|
<p>No links here!</p>
|
|
</body>
|
|
</html>
|
|
`
|
|
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 0 {
|
|
t.Errorf("expected 0 links from HTML without links, got %d", len(links))
|
|
}
|
|
}
|
|
|
|
func TestExtractLinksBaseURLWithPath(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/docs/guide/")
|
|
|
|
t.Run("relative URL resolves relative to base path", func(t *testing.T) {
|
|
html := `<a href="chapter1.html">Chapter 1</a>`
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(links))
|
|
}
|
|
expected := "https://example.com/docs/guide/chapter1.html"
|
|
if links[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, links[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("parent path resolution", func(t *testing.T) {
|
|
html := `<a href="../images/logo.png">Logo</a>`
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("expected 1 link, got %d", len(links))
|
|
}
|
|
expected := "https://example.com/docs/images/logo.png"
|
|
if links[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, links[0].String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExtractLinksDuplicateRemoval(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
|
|
html := `
|
|
<a href="/page.html">Page 1</a>
|
|
<a href="/page.html">Page 2</a>
|
|
<a href="https://example.com/page.html">Page 3</a>
|
|
<a href="/other.html">Other</a>
|
|
`
|
|
|
|
links, err := ExtractLinks(baseURL, []byte(html))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(links) != 2 {
|
|
t.Fatalf("expected 2 unique links, got %d", len(links))
|
|
}
|
|
|
|
expectedURLs := []string{
|
|
"https://example.com/page.html",
|
|
"https://example.com/other.html",
|
|
}
|
|
|
|
for _, expected := range expectedURLs {
|
|
found := false
|
|
for _, link := range links {
|
|
if link.String() == expected {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected URL %s not found", expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractLinksFromHTML(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/")
|
|
html := `<a href="/test.html">Test</a>`
|
|
|
|
links1, err1 := ExtractLinks(baseURL, []byte(html))
|
|
links2, err2 := ExtractLinksFromHTML(baseURL, []byte(html))
|
|
|
|
if err1 != nil || err2 != nil {
|
|
t.Fatal("both functions should return no error")
|
|
}
|
|
|
|
if len(links1) != len(links2) {
|
|
t.Fatalf("ExtractLinks returned %d links, ExtractLinksFromHTML returned %d", len(links1), len(links2))
|
|
}
|
|
|
|
if len(links1) > 0 && links1[0].String() != links2[0].String() {
|
|
t.Errorf("ExtractLinks returned %s, ExtractLinksFromHTML returned %s", links1[0].String(), links2[0].String())
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Crawler tests
|
|
// ============================================================
|
|
|
|
func TestNewCrawler(t *testing.T) {
|
|
cfg := &CrawlerConfig{
|
|
MaxDepth: 3,
|
|
FollowExternal: false,
|
|
OutputDir: "/tmp/test",
|
|
Parallel: 2,
|
|
UserAgent: "TestBot/1.0",
|
|
}
|
|
|
|
crawler := NewCrawler(cfg, nil)
|
|
|
|
if crawler == nil {
|
|
t.Fatal("NewCrawler returned nil")
|
|
}
|
|
if crawler.config.MaxDepth != 3 {
|
|
t.Errorf("expected MaxDepth 3, got %d", crawler.config.MaxDepth)
|
|
}
|
|
if crawler.config.FollowExternal != false {
|
|
t.Errorf("expected FollowExternal false, got %v", crawler.config.FollowExternal)
|
|
}
|
|
if crawler.config.OutputDir != "/tmp/test" {
|
|
t.Errorf("expected OutputDir /tmp/test, got %s", crawler.config.OutputDir)
|
|
}
|
|
if crawler.config.UserAgent != "TestBot/1.0" {
|
|
t.Errorf("expected UserAgent TestBot/1.0, got %s", crawler.config.UserAgent)
|
|
}
|
|
if crawler.config.Parallel != 2 {
|
|
t.Errorf("expected Parallel 2, got %d", crawler.config.Parallel)
|
|
}
|
|
if crawler.filter == nil {
|
|
t.Error("expected filter to be initialized")
|
|
}
|
|
if crawler.visited == nil {
|
|
t.Error("expected visited map to be initialized")
|
|
}
|
|
if crawler.queue == nil {
|
|
t.Error("expected queue to be initialized")
|
|
}
|
|
if crawler.stats == nil {
|
|
t.Error("expected stats to be initialized")
|
|
}
|
|
if crawler.ctx == nil {
|
|
t.Error("expected context to be initialized")
|
|
}
|
|
}
|
|
|
|
func TestNewCrawlerNilURLFilterConfig(t *testing.T) {
|
|
cfg := &CrawlerConfig{
|
|
MaxDepth: 0,
|
|
FollowExternal: false,
|
|
ExcludePatterns: nil,
|
|
IncludePatterns: nil,
|
|
}
|
|
|
|
crawler := NewCrawler(cfg, nil)
|
|
|
|
if crawler == nil {
|
|
t.Fatal("NewCrawler returned nil")
|
|
}
|
|
if crawler.filter == nil {
|
|
t.Fatal("expected filter to be initialized")
|
|
}
|
|
if len(crawler.filter.excludePatterns) != 0 {
|
|
t.Errorf("expected 0 exclude patterns, got %d", len(crawler.filter.excludePatterns))
|
|
}
|
|
if len(crawler.filter.includePatterns) != 0 {
|
|
t.Errorf("expected 0 include patterns, got %d", len(crawler.filter.includePatterns))
|
|
}
|
|
}
|
|
|
|
func TestNewCrawlerDefaultConfig(t *testing.T) {
|
|
cfg := &CrawlerConfig{}
|
|
crawler := NewCrawler(cfg, nil)
|
|
|
|
if crawler == nil {
|
|
t.Fatal("NewCrawler returned nil")
|
|
}
|
|
if crawler.config.MaxDepth != 0 {
|
|
t.Errorf("expected MaxDepth 0 as default, got %d", crawler.config.MaxDepth)
|
|
}
|
|
if crawler.config.Parallel != 0 {
|
|
t.Errorf("expected Parallel 0 as default, got %d", crawler.config.Parallel)
|
|
}
|
|
if crawler.stats == nil {
|
|
t.Error("expected stats to be initialized")
|
|
}
|
|
if crawler.stats.StartTime.IsZero() {
|
|
t.Error("expected StartTime to be set")
|
|
}
|
|
}
|
|
|
|
func TestGetStats(t *testing.T) {
|
|
cfg := &CrawlerConfig{}
|
|
crawler := NewCrawler(cfg, nil)
|
|
|
|
stats := crawler.GetStats()
|
|
|
|
if stats == nil {
|
|
t.Fatal("GetStats returned nil")
|
|
}
|
|
if stats.TotalURLs != 0 {
|
|
t.Errorf("expected TotalURLs 0, got %d", stats.TotalURLs)
|
|
}
|
|
if stats.DownloadedURLs != 0 {
|
|
t.Errorf("expected DownloadedURLs 0, got %d", stats.DownloadedURLs)
|
|
}
|
|
if stats.FailedURLs != 0 {
|
|
t.Errorf("expected FailedURLs 0, got %d", stats.FailedURLs)
|
|
}
|
|
if stats.SkippedURLs != 0 {
|
|
t.Errorf("expected SkippedURLs 0, got %d", stats.SkippedURLs)
|
|
}
|
|
if stats.TotalBytes != 0 {
|
|
t.Errorf("expected TotalBytes 0, got %d", stats.TotalBytes)
|
|
}
|
|
if stats.StartTime.IsZero() {
|
|
t.Error("expected StartTime to be set")
|
|
}
|
|
|
|
// Verify it returns the same pointer (not a copy)
|
|
if stats != crawler.stats {
|
|
t.Error("GetStats should return the same stats instance")
|
|
}
|
|
}
|
|
|
|
func TestGetStatsSummary(t *testing.T) {
|
|
stats := &CrawlerStats{
|
|
DownloadedURLs: 10,
|
|
TotalBytes: 1500000,
|
|
StartTime: time.Now().Add(-5 * time.Second),
|
|
EndTime: time.Now(),
|
|
}
|
|
|
|
summary := stats.GetStatsSummary()
|
|
|
|
if !strings.Contains(summary, "10") {
|
|
t.Errorf("summary should contain 10 downloaded files, got: %s", summary)
|
|
}
|
|
if !strings.Contains(summary, "1.5 MB") {
|
|
t.Errorf("summary should contain 1.5 MB, got: %s", summary)
|
|
}
|
|
if !strings.Contains(summary, "Downloaded:") {
|
|
t.Errorf("summary should start with 'Downloaded:', got: %s", summary)
|
|
}
|
|
|
|
// Test with EndTime set
|
|
stats.EndTime = time.Now()
|
|
summary = stats.GetStatsSummary()
|
|
if !strings.Contains(summary, "Downloaded:") {
|
|
t.Errorf("summary should be valid when EndTime is set, got: %s", summary)
|
|
}
|
|
|
|
// Test zero values
|
|
emptyStats := &CrawlerStats{}
|
|
summary = emptyStats.GetStatsSummary()
|
|
if summary == "" {
|
|
t.Error("summary should not be empty even for zero stats")
|
|
}
|
|
}
|
|
|
|
func TestHttpDetectContentType(t *testing.T) {
|
|
t.Run("HTML declaration", func(t *testing.T) {
|
|
data := []byte("<!DOCTYPE html><html><head></head><body></body></html>")
|
|
contentType := httpDetectContentType(data)
|
|
if contentType != "text/html" {
|
|
t.Errorf("expected text/html, got %s", contentType)
|
|
}
|
|
})
|
|
|
|
t.Run("HTML tag", func(t *testing.T) {
|
|
data := []byte("<html><head></head><body></body></html>")
|
|
contentType := httpDetectContentType(data)
|
|
if contentType != "text/html" {
|
|
t.Errorf("expected text/html, got %s", contentType)
|
|
}
|
|
})
|
|
|
|
t.Run("uppercase HTML", func(t *testing.T) {
|
|
data := []byte("<HTML><HEAD></HEAD><BODY></BODY></HTML>")
|
|
contentType := httpDetectContentType(data)
|
|
if contentType != "text/html" {
|
|
t.Errorf("expected text/html, got %s", contentType)
|
|
}
|
|
})
|
|
|
|
t.Run("non-HTML content", func(t *testing.T) {
|
|
data := []byte("<?xml version=\"1.0\"?><root></root>")
|
|
contentType := httpDetectContentType(data)
|
|
if contentType != "" {
|
|
t.Errorf("expected empty string, got %s", contentType)
|
|
}
|
|
})
|
|
|
|
t.Run("empty data", func(t *testing.T) {
|
|
contentType := httpDetectContentType([]byte{})
|
|
if contentType != "" {
|
|
t.Errorf("expected empty string, got %s", contentType)
|
|
}
|
|
})
|
|
|
|
t.Run("nil data", func(t *testing.T) {
|
|
contentType := httpDetectContentType(nil)
|
|
if contentType != "" {
|
|
t.Errorf("expected empty string, got %s", contentType)
|
|
}
|
|
})
|
|
|
|
t.Run("single byte", func(t *testing.T) {
|
|
contentType := httpDetectContentType([]byte{'x'})
|
|
if contentType != "" {
|
|
t.Errorf("expected empty string, got %s", contentType)
|
|
}
|
|
})
|
|
|
|
t.Run("binary data", func(t *testing.T) {
|
|
data := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
|
contentType := httpDetectContentType(data)
|
|
if contentType != "" {
|
|
t.Errorf("expected empty string for binary data, got %s", contentType)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestExtractCSSURLs tests CSS URL extraction.
|
|
func TestExtractCSSURLs(t *testing.T) {
|
|
baseURL, _ := url.Parse("https://example.com/css/")
|
|
|
|
t.Run("url function", func(t *testing.T) {
|
|
css := []byte(`body { background: url("bg.png"); }`)
|
|
urls := ExtractCSSURLs(baseURL, css)
|
|
if len(urls) != 1 {
|
|
t.Fatalf("expected 1 URL, got %d", len(urls))
|
|
}
|
|
expected := "https://example.com/css/bg.png"
|
|
if urls[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, urls[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("import rule", func(t *testing.T) {
|
|
css := []byte(`@import "style.css";`)
|
|
urls := ExtractCSSURLs(baseURL, css)
|
|
if len(urls) != 1 {
|
|
t.Fatalf("expected 1 URL, got %d", len(urls))
|
|
}
|
|
expected := "https://example.com/css/style.css"
|
|
if urls[0].String() != expected {
|
|
t.Errorf("expected %s, got %s", expected, urls[0].String())
|
|
}
|
|
})
|
|
|
|
t.Run("multiple urls", func(t *testing.T) {
|
|
css := []byte(`
|
|
body { background: url("bg.png"); }
|
|
div { background: url('icon.svg'); }
|
|
@import "theme.css";
|
|
`)
|
|
urls := ExtractCSSURLs(baseURL, css)
|
|
if len(urls) != 3 {
|
|
t.Fatalf("expected 3 URLs, got %d", len(urls))
|
|
}
|
|
})
|
|
|
|
t.Run("deduplicates", func(t *testing.T) {
|
|
css := []byte(`body { background: url("bg.png"); background: url("bg.png"); }`)
|
|
urls := ExtractCSSURLs(baseURL, css)
|
|
if len(urls) != 1 {
|
|
t.Fatalf("expected 1 unique URL, got %d", len(urls))
|
|
}
|
|
})
|
|
|
|
t.Run("empty", func(t *testing.T) {
|
|
urls := ExtractCSSURLs(baseURL, []byte(``))
|
|
if len(urls) != 0 {
|
|
t.Errorf("expected 0 URLs, got %d", len(urls))
|
|
}
|
|
})
|
|
|
|
t.Run("no urls", func(t *testing.T) {
|
|
css := []byte(`body { color: red; }`)
|
|
urls := ExtractCSSURLs(baseURL, css)
|
|
if len(urls) != 0 {
|
|
t.Errorf("expected 0 URLs, got %d", len(urls))
|
|
}
|
|
})
|
|
|
|
t.Run("skips non-http", func(t *testing.T) {
|
|
css := []byte(`body { background: url("data:image/png;base64,abc"); }`)
|
|
urls := ExtractCSSURLs(baseURL, css)
|
|
if len(urls) != 0 {
|
|
t.Errorf("expected 0 URLs for data: URI, got %d", len(urls))
|
|
}
|
|
})
|
|
}
|