Files
libnovel/scraper/internal/storage/scrape_integration_test.go
Admin cff0c78b4f perf(scraper): use direct HTTP for chapter text fetching, bypass Browserless
novelfire.net chapter content is server-rendered, so Browserless is not
needed. Add a dedicated chapterClient (always StrategyDirect) to Scraper
and use it in ScrapeChapterText, removing the now-irrelevant WaitFor /
RejectResourceTypes / GotoOptions fields from the ContentRequest.
2026-03-03 20:40:01 +05:00

204 lines
6.6 KiB
Go

//go:build integration
// Integration tests that combine live scraping (Browserless) with real storage
// (MinIO + PocketBase) via HybridStore.
//
// These tests require ALL THREE services to be running. They are gated behind
// the "integration" build tag and skipped when any service URL is missing.
//
// Run with:
//
// BROWSERLESS_URL=http://localhost:3030 \
// MINIO_ENDPOINT=localhost:9000 \
// POCKETBASE_URL=http://localhost:8090 \
// go test -v -tags integration -timeout 600s \
// github.com/libnovel/scraper/internal/storage
package storage
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/libnovel/scraper/internal/browser"
"github.com/libnovel/scraper/internal/novelfire"
"github.com/libnovel/scraper/internal/scraper"
)
const (
scrapeTestBookURL = "https://novelfire.net/book/a-dragon-against-the-whole-world"
scrapeTestBookSlug = "a-dragon-against-the-whole-world"
)
// newScrapeAndStoreFixture builds a novelfire Scraper and a HybridStore,
// skipping the test if any required env var is absent.
func newScrapeAndStoreFixture(t *testing.T) (*novelfire.Scraper, *HybridStore) {
t.Helper()
browserlessURL := os.Getenv("BROWSERLESS_URL")
if browserlessURL == "" {
t.Skip("BROWSERLESS_URL not set — skipping scrape+store integration test")
}
if os.Getenv("MINIO_ENDPOINT") == "" {
t.Skip("MINIO_ENDPOINT not set — skipping scrape+store integration test")
}
if os.Getenv("POCKETBASE_URL") == "" {
t.Skip("POCKETBASE_URL not set — skipping scrape+store integration test")
}
client := browser.NewContentClient(browser.Config{
BaseURL: browserlessURL,
Token: os.Getenv("BROWSERLESS_TOKEN"),
Timeout: 120 * time.Second,
MaxConcurrent: 1,
})
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
sc := novelfire.New(client, log, client, nil, nil)
hs := newTestHybridStore(t)
return sc, hs
}
// TestScrapeAndStore_BookMetadata scrapes the test book's metadata and stores
// it via HybridStore.WriteMetadata, then verifies a ReadMetadata round-trip.
func TestScrapeAndStore_BookMetadata(t *testing.T) {
sc, hs := newScrapeAndStoreFixture(t)
slug := scrapeTestBookSlug + "-scrapetest"
t.Cleanup(func() {
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = hs.pb.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug))
})
// 1. Scrape metadata from the live site.
scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer scrapeCancel()
meta, err := sc.ScrapeMetadata(scrapeCtx, scrapeTestBookURL)
if err != nil {
t.Fatalf("ScrapeMetadata: %v", err)
}
t.Logf("scraped: slug=%q title=%q author=%q totalChapters=%d",
meta.Slug, meta.Title, meta.Author, meta.TotalChapters)
// Override slug with our test-specific value to avoid polluting real data.
meta.Slug = slug
// 2. Write to HybridStore.
storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer storeCancel()
if err := hs.WriteMetadata(storeCtx, meta); err != nil {
t.Fatalf("WriteMetadata: %v", err)
}
// 3. Read back and verify.
got, found, err := hs.ReadMetadata(storeCtx, slug)
if err != nil {
t.Fatalf("ReadMetadata: %v", err)
}
if !found {
t.Fatal("ReadMetadata: not found after WriteMetadata")
}
t.Logf("read back: title=%q author=%q totalChapters=%d", got.Title, got.Author, got.TotalChapters)
if got.Title == "" {
t.Error("Title is empty after round-trip")
}
if got.Author == "" {
t.Error("Author is empty after round-trip")
}
if got.TotalChapters < 1 {
t.Errorf("TotalChapters = %d, want >= 1", got.TotalChapters)
}
}
// TestScrapeAndStore_First3Chapters scrapes chapters 1, 2, and 3 from the
// live site and stores each via HybridStore.WriteChapter, then verifies
// ReadChapter returns non-empty markdown with the expected header.
func TestScrapeAndStore_First3Chapters(t *testing.T) {
sc, hs := newScrapeAndStoreFixture(t)
// Use a unique test slug so we don't pollute the real book.
slug := fmt.Sprintf("%s-chtest-%d", scrapeTestBookSlug, time.Now().UnixMilli()%100000)
t.Cleanup(func() {
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = hs.pb.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug))
})
// Pre-build chapter refs (known URLs for this test book).
refs := []scraper.ChapterRef{
{Number: 1, Title: "Chapter 1", Volume: 0, URL: scrapeTestBookURL + "/chapter-1"},
{Number: 2, Title: "Chapter 2", Volume: 0, URL: scrapeTestBookURL + "/chapter-2"},
{Number: 3, Title: "Chapter 3", Volume: 0, URL: scrapeTestBookURL + "/chapter-3"},
}
for _, ref := range refs {
ref := ref // capture loop variable
t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) {
// 1. Scrape chapter text.
scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 120*time.Second)
defer scrapeCancel()
ch, err := sc.ScrapeChapterText(scrapeCtx, ref)
if err != nil {
t.Fatalf("ScrapeChapterText(%d): %v", ref.Number, err)
}
t.Logf("scraped chapter %d: %d bytes of markdown", ref.Number, len(ch.Text))
if len(ch.Text) < 100 {
t.Errorf("scraped text too short (%d bytes)", len(ch.Text))
}
// 2. Write to HybridStore.
storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer storeCancel()
if err := hs.WriteChapter(storeCtx, slug, ch); err != nil {
t.Fatalf("WriteChapter(%d): %v", ref.Number, err)
}
// 3. Read back and verify.
got, err := hs.ReadChapter(storeCtx, slug, ref.Number)
if err != nil {
t.Fatalf("ReadChapter(%d): %v", ref.Number, err)
}
if got == "" {
t.Fatalf("ReadChapter(%d): returned empty string", ref.Number)
}
if len(got) < 100 {
t.Errorf("ReadChapter(%d): content too short (%d bytes)", ref.Number, len(got))
}
// WriteChapter prepends "# <title>\n\n".
if !strings.HasPrefix(got, "# ") {
t.Errorf("chapter %d: stored content does not start with markdown header: %q",
ref.Number, got[:min(len(got), 60)])
}
// Verify the original scraped text body is present.
if !strings.Contains(got, ch.Text[:min(len(ch.Text), 50)]) {
t.Errorf("chapter %d: stored content does not contain scraped text excerpt", ref.Number)
}
t.Logf("chapter %d stored and verified: %d bytes", ref.Number, len(got))
})
}
// After all chapters written, verify count.
countCtx, countCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer countCancel()
count := hs.CountChapters(countCtx, slug)
if count != len(refs) {
t.Errorf("CountChapters = %d, want %d", count, len(refs))
}
}