327 lines
10 KiB
Go
327 lines
10 KiB
Go
// Package crawlertest provides a standardized integration test harness for
|
|
// validating that each SiteCrawler still functions against its real target URL.
|
|
//
|
|
// Each crawler package should have a TestXxx_LiveIntegration test that calls
|
|
// crawlertest.RunLiveIntegration(). This tests three real-world failure modes:
|
|
//
|
|
// 1. DISCOVER STAGE - Can we still hit the search URL and get job listings?
|
|
// Detects: search API endpoint moved, query param format changed, auth required.
|
|
//
|
|
// 2. FETCH STAGE - Can we still fetch a single job detail page?
|
|
// Detects: detail URL pattern changed, page returns 403/404, rate limiting.
|
|
//
|
|
// 3. PARSE STAGE - Can we still extract structured data from the response?
|
|
// Detects: HTML/JSON schema changed, title/company/content extraction broken.
|
|
package crawlertest
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
|
|
)
|
|
|
|
// LiveTestConfig defines what a live integration test expects from a crawler.
|
|
type LiveTestConfig struct {
|
|
// PlatformName is the human-readable platform name (e.g., "Amazon", "Google").
|
|
PlatformName string
|
|
|
|
// Crawler is the SiteCrawler instance to test.
|
|
Crawler crawler.SiteCrawler
|
|
|
|
// TargetURL is the real search/listing URL to test discovery against.
|
|
TargetURL string
|
|
|
|
// ExpectedCanonicalPrefix is the prefix each CanonicalID should start with
|
|
// (e.g., "amazon:", "google:"). Empty string skips this check.
|
|
ExpectedCanonicalPrefix string
|
|
|
|
// ExpectedCompany is the expected Company field in FetchJobDetail results.
|
|
// Empty string skips this check.
|
|
ExpectedCompany string
|
|
|
|
// MinDiscoverCount is the minimum number of jobs DiscoverJobs should return.
|
|
// Defaults to 1 if not set.
|
|
MinDiscoverCount int
|
|
|
|
// MinContentLength is the minimum byte length of RawContent in FetchJobDetail.
|
|
// Defaults to 100 if not set.
|
|
MinContentLength int
|
|
|
|
// Timeout is the context timeout for the entire test. Defaults to 30s.
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// RunLiveIntegration runs a standardized 3-stage integration test against real
|
|
// targets. It gracefully skips on network errors (offline/sandbox) but FAILS
|
|
// loudly when the crawler reaches the site but can't parse data correctly.
|
|
//
|
|
// This is the test that tells you "the site changed and our crawler is broken".
|
|
//
|
|
// Usage in each crawler package's _test.go:
|
|
//
|
|
// func TestAmazonCrawler_LiveIntegration(t *testing.T) {
|
|
// crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
|
|
// PlatformName: "Amazon",
|
|
// Crawler: NewAmazonCrawler(),
|
|
// TargetURL: "https://www.amazon.jobs/en/search.json?...",
|
|
// ExpectedCanonicalPrefix: "amazon:",
|
|
// ExpectedCompany: "Amazon",
|
|
// })
|
|
// }
|
|
func RunLiveIntegration(t *testing.T, cfg LiveTestConfig) {
|
|
t.Helper()
|
|
|
|
// Apply defaults
|
|
if cfg.MinDiscoverCount <= 0 {
|
|
cfg.MinDiscoverCount = 1
|
|
}
|
|
if cfg.MinContentLength <= 0 {
|
|
cfg.MinContentLength = 100
|
|
}
|
|
if cfg.Timeout <= 0 {
|
|
cfg.Timeout = 30 * time.Second
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
|
|
defer cancel()
|
|
|
|
// =========================================================================
|
|
// STAGE 1: DISCOVER — Can we reach the search URL and get job listings?
|
|
// =========================================================================
|
|
t.Run("Stage1_Discover", func(t *testing.T) {
|
|
t.Logf("[%s] Discovering jobs from: %s", cfg.PlatformName, cfg.TargetURL)
|
|
|
|
discovered, err := cfg.Crawler.DiscoverJobs(ctx, cfg.TargetURL)
|
|
if err != nil {
|
|
if isNetworkError(err) {
|
|
t.Skipf("SKIP (offline): %v", err)
|
|
}
|
|
t.Fatalf("❌ DISCOVER FAILED: %v\n"+
|
|
" → The search URL may have changed format or the API endpoint moved.\n"+
|
|
" → Target URL: %s", err, cfg.TargetURL)
|
|
}
|
|
|
|
if len(discovered) == 0 {
|
|
t.Fatalf("❌ DISCOVER RETURNED 0 JOBS\n"+
|
|
" → The search results page format may have changed.\n"+
|
|
" → Check if the target URL still returns results in a browser.\n"+
|
|
" → Target URL: %s", cfg.TargetURL)
|
|
}
|
|
|
|
if len(discovered) < cfg.MinDiscoverCount {
|
|
t.Errorf("⚠️ DISCOVER LOW COUNT: got %d jobs, expected at least %d\n"+
|
|
" → The search API may be returning fewer results than expected.",
|
|
len(discovered), cfg.MinDiscoverCount)
|
|
}
|
|
|
|
t.Logf("✅ Discovered %d jobs", len(discovered))
|
|
|
|
// Validate first result structure
|
|
first := discovered[0]
|
|
if first.ApplyURL == "" {
|
|
t.Errorf("❌ First discovered job has empty ApplyURL")
|
|
}
|
|
if first.CanonicalID == "" {
|
|
t.Errorf("❌ First discovered job has empty CanonicalID")
|
|
}
|
|
if cfg.ExpectedCanonicalPrefix != "" && !strings.HasPrefix(first.CanonicalID, cfg.ExpectedCanonicalPrefix) {
|
|
t.Errorf("❌ CANONICAL ID FORMAT CHANGED: got %q, expected prefix %q\n"+
|
|
" → The job ID extraction pattern may need updating.",
|
|
first.CanonicalID, cfg.ExpectedCanonicalPrefix)
|
|
}
|
|
|
|
t.Logf(" First job: ID=%s URL=%s", first.CanonicalID, first.ApplyURL)
|
|
})
|
|
|
|
// Re-discover for stage 2 (subtests get their own scope)
|
|
discovered, err := cfg.Crawler.DiscoverJobs(ctx, cfg.TargetURL)
|
|
if err != nil || len(discovered) == 0 {
|
|
// Stage 1 already reported the failure
|
|
return
|
|
}
|
|
|
|
// =========================================================================
|
|
// STAGE 2: FETCH — Can we fetch a job detail page?
|
|
// =========================================================================
|
|
firstJob := discovered[0]
|
|
var detail *crawler.ExtractedJob
|
|
|
|
t.Run("Stage2_Fetch", func(t *testing.T) {
|
|
t.Logf("[%s] Fetching job detail: %s", cfg.PlatformName, firstJob.ApplyURL)
|
|
|
|
var fetchErr error
|
|
detail, fetchErr = cfg.Crawler.FetchJobDetail(ctx, firstJob.ApplyURL)
|
|
if fetchErr != nil {
|
|
if isNetworkError(fetchErr) {
|
|
t.Skipf("SKIP (offline): %v", fetchErr)
|
|
}
|
|
t.Fatalf("❌ FETCH FAILED: %v\n"+
|
|
" → The job detail URL pattern may have changed.\n"+
|
|
" → The page may now require JavaScript rendering.\n"+
|
|
" → Job URL: %s", fetchErr, firstJob.ApplyURL)
|
|
}
|
|
|
|
if detail == nil {
|
|
t.Fatalf("❌ FETCH RETURNED nil ExtractedJob")
|
|
}
|
|
|
|
t.Logf("✅ Fetched job detail successfully")
|
|
})
|
|
|
|
if detail == nil {
|
|
return
|
|
}
|
|
|
|
// =========================================================================
|
|
// STAGE 3: PARSE — Can we extract structured data from the response?
|
|
// =========================================================================
|
|
t.Run("Stage3_Parse", func(t *testing.T) {
|
|
t.Logf("[%s] Validating parsed fields", cfg.PlatformName)
|
|
failures := 0
|
|
|
|
// Title
|
|
if strings.TrimSpace(detail.Title) == "" {
|
|
t.Errorf("❌ PARSE: Title is empty\n"+
|
|
" → The title extraction selector/pattern may have changed.\n"+
|
|
" → Job URL: %s", firstJob.ApplyURL)
|
|
failures++
|
|
} else {
|
|
t.Logf(" Title: %s", detail.Title)
|
|
}
|
|
|
|
// Company
|
|
if strings.TrimSpace(detail.Company) == "" {
|
|
t.Errorf("❌ PARSE: Company is empty\n"+
|
|
" → The company extraction logic may have changed.")
|
|
failures++
|
|
} else {
|
|
t.Logf(" Company: %s", detail.Company)
|
|
}
|
|
|
|
if cfg.ExpectedCompany != "" && !strings.Contains(detail.Company, cfg.ExpectedCompany) {
|
|
t.Errorf("⚠️ PARSE: Company mismatch: got %q, expected to contain %q\n"+
|
|
" → The company name format may have changed.",
|
|
detail.Company, cfg.ExpectedCompany)
|
|
}
|
|
|
|
// Location
|
|
if strings.TrimSpace(detail.Location) == "" {
|
|
t.Errorf("⚠️ PARSE: Location is empty\n"+
|
|
" → Location extraction may have changed.")
|
|
} else {
|
|
t.Logf(" Location: %s", detail.Location)
|
|
}
|
|
|
|
// CanonicalID
|
|
if detail.CanonicalID == "" {
|
|
t.Errorf("❌ PARSE: CanonicalID is empty")
|
|
failures++
|
|
}
|
|
|
|
// ApplyURL
|
|
if detail.ApplyURL == "" {
|
|
t.Errorf("❌ PARSE: ApplyURL is empty")
|
|
failures++
|
|
}
|
|
|
|
// RawContent (the critical one for downstream LLM analysis)
|
|
contentLen := len(detail.RawContent)
|
|
if contentLen == 0 {
|
|
t.Errorf("❌ PARSE: RawContent is EMPTY\n"+
|
|
" → The content extraction is completely broken.\n"+
|
|
" → The page structure or API response schema has changed.\n"+
|
|
" → Job URL: %s", firstJob.ApplyURL)
|
|
failures++
|
|
} else if contentLen < cfg.MinContentLength {
|
|
t.Errorf("⚠️ PARSE: RawContent is too short (%d bytes, min %d)\n"+
|
|
" → The content extraction may be partially broken.\n"+
|
|
" → Job URL: %s", contentLen, cfg.MinContentLength, firstJob.ApplyURL)
|
|
} else {
|
|
t.Logf(" Content: %d bytes", contentLen)
|
|
}
|
|
|
|
if failures == 0 {
|
|
t.Logf("✅ All parse checks passed")
|
|
} else {
|
|
t.Logf("❌ %d parse check(s) failed — crawler needs fixing", failures)
|
|
}
|
|
})
|
|
}
|
|
|
|
// isNetworkError checks if an error is a network/DNS/connectivity issue
|
|
// (as opposed to a parsing or HTTP status error from a reachable server).
|
|
func isNetworkError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := err.Error()
|
|
networkPatterns := []string{
|
|
"no such host",
|
|
"dial tcp",
|
|
"connection refused",
|
|
"network is unreachable",
|
|
"i/o timeout",
|
|
"TLS handshake timeout",
|
|
"operation not permitted",
|
|
"connect: operation not permitted",
|
|
}
|
|
for _, pattern := range networkPatterns {
|
|
if strings.Contains(msg, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FormatHealthReport generates a summary table from multiple test results.
|
|
// This can be called from a top-level aggregation test.
|
|
func FormatHealthReport(results []HealthResult) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("%-15s | %-10s | %-10s | %-10s | %s\n",
|
|
"Platform", "Discover", "Fetch", "Content", "Status"))
|
|
sb.WriteString(strings.Repeat("-", 70) + "\n")
|
|
for _, r := range results {
|
|
discoverStr := fmt.Sprintf("%d jobs", r.DiscoverCount)
|
|
fetchStr := "✓"
|
|
contentStr := fmt.Sprintf("%d B", r.ContentLength)
|
|
status := "✅ HEALTHY"
|
|
|
|
if r.Skipped {
|
|
status = "⏭ SKIPPED"
|
|
discoverStr = "-"
|
|
fetchStr = "-"
|
|
contentStr = "-"
|
|
} else if r.Error != nil {
|
|
status = "❌ BROKEN"
|
|
if r.DiscoverCount == 0 {
|
|
discoverStr = "FAIL"
|
|
fetchStr = "-"
|
|
contentStr = "-"
|
|
} else if r.ContentLength == 0 {
|
|
fetchStr = "FAIL"
|
|
contentStr = "-"
|
|
}
|
|
} else if r.DiscoverCount == 0 {
|
|
status = "⚠️ DEGRADED"
|
|
discoverStr = "0 ⚠️"
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("%-15s | %-10s | %-10s | %-10s | %s\n",
|
|
r.Platform, discoverStr, fetchStr, contentStr, status))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// HealthResult is a structured result from a crawler health check.
|
|
type HealthResult struct {
|
|
Platform string
|
|
DiscoverCount int
|
|
ContentLength int
|
|
Error error
|
|
Skipped bool
|
|
}
|