- Remove dead code: browser cdp/content_scrape strategies, writer package, printUsage, downloadAndStoreCoverCLI in main.go - Fix bugs: defer-in-loop in pocketbase deleteWhere, listAll() pagination hard cap removed, splitChapterTitle off-by-one in date extraction - Split server.go (~1700 lines) into focused handler files: handlers_audio, handlers_browse, handlers_progress, handlers_ranking, handlers_scrape - Export htmlutil.AttrVal/TextContent/ResolveURL; add storage/coverutil.go to consolidate duplicate helpers - Flatten deeply nested conditionals: voices() early-return guards, ScrapeCatalogue next-link double attr scan, chapterNumberFromKey dead strings.Cut line, splitChapterTitle double-nested unit/suffix loop - Add unit tests: htmlutil (9 funcs), novelfire ScrapeMetadata (3 cases), orchestrator Run (5 cases), storage chapterNumberFromKey/splitChapterTitle (22 cases); all pass with go build/vet/test clean
819 lines
28 KiB
Go
819 lines
28 KiB
Go
//go:build integration
|
||
|
||
// End-to-end integration test for libnovel.
|
||
//
|
||
// Scenario (executed in order):
|
||
// 1. Health-check all Docker services (PocketBase, MinIO, Browserless, scraper).
|
||
// 2. Register a test user in the app_users PocketBase collection.
|
||
// 3. Scrape the popular-ranking page 1 and capture the first book.
|
||
// 4. Scrape full metadata for that book and persist it; verify in PocketBase.
|
||
// 5. Scrape chapters 1–3 and persist them; verify in MinIO + PocketBase.
|
||
// 6. Generate TTS audio for the first 100 chars of each chapter via the scraper
|
||
// HTTP API; verify MinIO object + PocketBase audio_cache entry.
|
||
// 7. Fetch presigned URLs for each chapter's markdown and audio; verify HTTP 200.
|
||
//
|
||
// Prerequisites (all must be running):
|
||
//
|
||
// docker-compose up -d minio pocketbase browserless scraper
|
||
//
|
||
// Run with:
|
||
//
|
||
// BROWSERLESS_URL=http://localhost:3030 \
|
||
// MINIO_ENDPOINT=localhost:9000 \
|
||
// POCKETBASE_URL=http://localhost:8090 \
|
||
// SCRAPER_URL=http://localhost:8080 \
|
||
// go test -v -tags integration -timeout 900s \
|
||
// github.com/libnovel/scraper/internal/e2e
|
||
package e2e
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/libnovel/scraper/internal/browser"
|
||
"github.com/libnovel/scraper/internal/novelfire"
|
||
"github.com/libnovel/scraper/internal/scraper"
|
||
"github.com/libnovel/scraper/internal/storage"
|
||
)
|
||
|
||
// ─── env helpers ─────────────────────────────────────────────────────────────
|
||
|
||
func envOr(key, def string) string {
|
||
if v := os.Getenv(key); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
// ─── fixture ─────────────────────────────────────────────────────────────────
|
||
|
||
type e2eFixture struct {
|
||
sc *novelfire.Scraper
|
||
hs *storage.HybridStore
|
||
scraperURL string // base URL of the running scraper HTTP server
|
||
pbBaseURL string
|
||
pbEmail string
|
||
pbPassword string
|
||
}
|
||
|
||
func newE2EFixture(t *testing.T) *e2eFixture {
|
||
t.Helper()
|
||
|
||
browserlessURL := envOr("BROWSERLESS_URL", "")
|
||
if browserlessURL == "" {
|
||
t.Skip("BROWSERLESS_URL not set — skipping e2e test")
|
||
}
|
||
if os.Getenv("MINIO_ENDPOINT") == "" {
|
||
t.Skip("MINIO_ENDPOINT not set — skipping e2e test")
|
||
}
|
||
if os.Getenv("POCKETBASE_URL") == "" {
|
||
t.Skip("POCKETBASE_URL not set — skipping e2e test")
|
||
}
|
||
scraperURL := envOr("SCRAPER_URL", "http://localhost:8080")
|
||
|
||
pbBaseURL := envOr("POCKETBASE_URL", "http://localhost:8090")
|
||
pbEmail := envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local")
|
||
pbPassword := envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123")
|
||
|
||
pbCfg := storage.PocketBaseConfig{
|
||
BaseURL: pbBaseURL,
|
||
AdminEmail: pbEmail,
|
||
AdminPassword: pbPassword,
|
||
}
|
||
minioCfg := storage.MinioConfig{
|
||
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
|
||
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
|
||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||
UseSSL: envOr("MINIO_USE_SSL", "false") == "true",
|
||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
|
||
if err != nil {
|
||
t.Fatalf("NewHybridStore: %v", err)
|
||
}
|
||
|
||
// directClient: plain HTTP GET — used for chapter text, metadata, and ranking
|
||
// (novelfire.net serves these pages server-side; no JS rendering needed).
|
||
directClient := browser.NewDirectHTTPClient(browser.Config{
|
||
Timeout: 60 * time.Second,
|
||
MaxConcurrent: 2,
|
||
})
|
||
// urlClient: Browserless content strategy — used only for chapter-list
|
||
// pagination pages which require JS rendering to populate the list.
|
||
urlClient := browser.NewContentClient(browser.Config{
|
||
BaseURL: browserlessURL,
|
||
Token: os.Getenv("BROWSERLESS_TOKEN"),
|
||
Timeout: 120 * time.Second,
|
||
MaxConcurrent: 2,
|
||
})
|
||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
sc := novelfire.New(directClient, log, urlClient, directClient, nil)
|
||
|
||
return &e2eFixture{
|
||
sc: sc,
|
||
hs: hs,
|
||
scraperURL: scraperURL,
|
||
pbBaseURL: pbBaseURL,
|
||
pbEmail: pbEmail,
|
||
pbPassword: pbPassword,
|
||
}
|
||
}
|
||
|
||
// ─── The single end-to-end test ───────────────────────────────────────────────
|
||
|
||
// TestE2E_FullScenario executes the complete end-to-end scenario in order.
|
||
func TestE2E_FullScenario(t *testing.T) {
|
||
f := newE2EFixture(t)
|
||
|
||
// ── Step 1: Health-check services ────────────────────────────────────────
|
||
t.Run("step1_health_checks", func(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
// PocketBase health
|
||
pbHealth := f.pbBaseURL + "/api/health"
|
||
checkHTTP(t, ctx, pbHealth, "PocketBase")
|
||
|
||
// MinIO health — the MinIO console liveness endpoint
|
||
minioEndpoint := envOr("MINIO_ENDPOINT", "localhost:9000")
|
||
scheme := "http"
|
||
if envOr("MINIO_USE_SSL", "false") == "true" {
|
||
scheme = "https"
|
||
}
|
||
minioHealth := fmt.Sprintf("%s://%s/minio/health/live", scheme, minioEndpoint)
|
||
checkHTTP(t, ctx, minioHealth, "MinIO")
|
||
|
||
// Browserless health — /pressure is the liveness endpoint
|
||
browserlessURL := envOr("BROWSERLESS_URL", "http://localhost:3030")
|
||
blHealth := browserlessURL + "/pressure"
|
||
checkHTTP(t, ctx, blHealth, "Browserless")
|
||
|
||
// Scraper server health — wait up to 10 s for it to be ready
|
||
scraperHealth := f.scraperURL + "/health"
|
||
waitForHTTP(t, ctx, scraperHealth, "scraper server", 10*time.Second)
|
||
})
|
||
|
||
// ── Step 2: Register test user ────────────────────────────────────────────
|
||
var testUsername string
|
||
t.Run("step2_register_user", func(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
testUsername = fmt.Sprintf("e2euser-%d", time.Now().UnixMilli()%100000)
|
||
passwordHash := "pbkdf2:sha256:dummy-hash-for-test"
|
||
|
||
t.Cleanup(func() {
|
||
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cleanCancel()
|
||
deleteAppUser(t, f, cleanCtx, testUsername)
|
||
})
|
||
|
||
if err := createAppUser(ctx, f, testUsername, passwordHash, "reader"); err != nil {
|
||
t.Fatalf("createAppUser: %v", err)
|
||
}
|
||
t.Logf("created user %q", testUsername)
|
||
|
||
// Verify the user exists in PocketBase.
|
||
rec, err := getAppUserByUsername(ctx, f, testUsername)
|
||
if err != nil {
|
||
t.Fatalf("getAppUserByUsername: %v", err)
|
||
}
|
||
if rec == nil {
|
||
t.Fatal("user not found in app_users after creation")
|
||
}
|
||
if rec["username"] != testUsername {
|
||
t.Errorf("username = %q, want %q", rec["username"], testUsername)
|
||
}
|
||
t.Logf("user verified in PocketBase: id=%v username=%v role=%v", rec["id"], rec["username"], rec["role"])
|
||
})
|
||
|
||
// ── Step 3: Scrape ranking page 1, capture first book ────────────────────
|
||
var firstBook scraper.BookMeta
|
||
t.Run("step3_scrape_ranking", func(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||
defer cancel()
|
||
|
||
entries, errs := f.sc.ScrapeRanking(ctx, 1) // maxPages=1 → only page 1
|
||
|
||
select {
|
||
case meta, ok := <-entries:
|
||
if !ok {
|
||
t.Fatal("ranking channel closed without any entry")
|
||
}
|
||
firstBook = meta
|
||
case err := <-errs:
|
||
t.Fatalf("ScrapeRanking error: %v", err)
|
||
case <-ctx.Done():
|
||
t.Fatal("ScrapeRanking timed out waiting for first entry")
|
||
}
|
||
|
||
// Drain remaining entries and errors.
|
||
for range entries {
|
||
}
|
||
for range errs {
|
||
}
|
||
|
||
if firstBook.Slug == "" {
|
||
t.Fatal("first book has empty slug")
|
||
}
|
||
if firstBook.Title == "" {
|
||
t.Fatal("first book has empty title")
|
||
}
|
||
if firstBook.SourceURL == "" {
|
||
t.Fatal("first book has empty SourceURL")
|
||
}
|
||
t.Logf("first ranked book: slug=%q title=%q rank=%d url=%s",
|
||
firstBook.Slug, firstBook.Title, firstBook.Ranking, firstBook.SourceURL)
|
||
})
|
||
|
||
if firstBook.Slug == "" || firstBook.SourceURL == "" {
|
||
t.Fatal("cannot continue: step3 did not produce a valid first book")
|
||
}
|
||
|
||
// Use a unique slug for the test to avoid colliding with real scraped data.
|
||
testSlug := fmt.Sprintf("%s-e2e-%d", firstBook.Slug, time.Now().UnixMilli()%100000)
|
||
t.Logf("using test slug: %q", testSlug)
|
||
|
||
// Register cleanup for all data written by subsequent steps.
|
||
t.Cleanup(func() {
|
||
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cleanCancel()
|
||
cleanupTestData(t, f, cleanCtx, testSlug)
|
||
})
|
||
|
||
// ── Step 4: Scrape book metadata and persist ──────────────────────────────
|
||
var fullMeta scraper.BookMeta
|
||
t.Run("step4_scrape_metadata", func(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||
defer cancel()
|
||
|
||
meta, err := f.sc.ScrapeMetadata(ctx, firstBook.SourceURL)
|
||
if err != nil {
|
||
t.Fatalf("ScrapeMetadata: %v", err)
|
||
}
|
||
t.Logf("scraped metadata: title=%q author=%q totalChapters=%d",
|
||
meta.Title, meta.Author, meta.TotalChapters)
|
||
|
||
// Override slug so data lands under our test slug.
|
||
meta.Slug = testSlug
|
||
fullMeta = meta
|
||
|
||
storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer storeCancel()
|
||
|
||
if err := f.hs.WriteMetadata(storeCtx, meta); err != nil {
|
||
t.Fatalf("WriteMetadata: %v", err)
|
||
}
|
||
|
||
// Verify in PocketBase.
|
||
got, found, err := f.hs.ReadMetadata(storeCtx, testSlug)
|
||
if err != nil {
|
||
t.Fatalf("ReadMetadata: %v", err)
|
||
}
|
||
if !found {
|
||
t.Fatal("book not found in PocketBase after WriteMetadata")
|
||
}
|
||
if got.Title == "" {
|
||
t.Error("book title is empty after round-trip")
|
||
}
|
||
if got.Author == "" {
|
||
t.Logf("WARNING: book author is empty after round-trip (site may not expose author for this book)")
|
||
}
|
||
t.Logf("PocketBase verified: title=%q author=%q totalChapters=%d", got.Title, got.Author, got.TotalChapters)
|
||
})
|
||
|
||
if fullMeta.SourceURL == "" {
|
||
fullMeta.SourceURL = firstBook.SourceURL
|
||
}
|
||
|
||
// ── Step 5: Scrape first 3 chapters and persist ───────────────────────────
|
||
var chapterRefs []scraper.ChapterRef
|
||
t.Run("step5_scrape_chapters", func(t *testing.T) {
|
||
// Fetch only page 1 of the chapter list from
|
||
// https://novelfire.net/book/{slug}/chapters?page=1
|
||
// to avoid paginating through hundreds of pages for popular books.
|
||
listCtx, listCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer listCancel()
|
||
|
||
chaptersPageURL := firstBook.SourceURL + "/chapters?page=1"
|
||
refs, err := scrapeChapterListPage1(listCtx, f, chaptersPageURL)
|
||
if err != nil {
|
||
t.Fatalf("scrapeChapterListPage1: %v", err)
|
||
}
|
||
if len(refs) == 0 {
|
||
t.Fatal("chapter list page 1 returned no chapters")
|
||
}
|
||
t.Logf("chapter list page 1: %d chapters found", len(refs))
|
||
|
||
// Take the first 3 (or fewer if page 1 has < 3 chapters).
|
||
n := 3
|
||
if len(refs) < n {
|
||
n = len(refs)
|
||
}
|
||
chapterRefs = refs[:n]
|
||
t.Logf("will scrape first %d chapters: %v", n, chapterNumbers(chapterRefs))
|
||
|
||
for _, ref := range chapterRefs {
|
||
ref := ref
|
||
t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) {
|
||
scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||
defer scrapeCancel()
|
||
|
||
ch, err := f.sc.ScrapeChapterText(scrapeCtx, ref)
|
||
if err != nil {
|
||
t.Fatalf("ScrapeChapterText(%d): %v", ref.Number, err)
|
||
}
|
||
t.Logf("scraped chapter %d: %d bytes", ref.Number, len(ch.Text))
|
||
if len(ch.Text) < 50 {
|
||
t.Errorf("chapter %d text too short (%d bytes)", ref.Number, len(ch.Text))
|
||
}
|
||
|
||
// Override ref slug with our test slug.
|
||
ch.Ref.Number = ref.Number
|
||
ch.Ref.Title = ref.Title
|
||
|
||
storeCtx, storeCancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||
defer storeCancel()
|
||
|
||
if err := f.hs.WriteChapter(storeCtx, testSlug, ch); err != nil {
|
||
t.Fatalf("WriteChapter(%d): %v", ref.Number, err)
|
||
}
|
||
|
||
// Verify in MinIO via ReadChapter.
|
||
got, err := f.hs.ReadChapter(storeCtx, testSlug, ref.Number)
|
||
if err != nil {
|
||
t.Fatalf("ReadChapter(%d): %v", ref.Number, err)
|
||
}
|
||
if got == "" {
|
||
t.Errorf("chapter %d: ReadChapter returned empty content", ref.Number)
|
||
}
|
||
if !strings.HasPrefix(got, "# ") {
|
||
t.Errorf("chapter %d: stored content missing markdown header (got %q)", ref.Number, got[:min(len(got), 80)])
|
||
}
|
||
|
||
// Verify PocketBase chapters_idx entry.
|
||
idxCtx, idxCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer idxCancel()
|
||
count := f.hs.CountChapters(idxCtx, testSlug)
|
||
if count == 0 {
|
||
t.Errorf("chapter %d: chapters_idx count = 0 after WriteChapter", ref.Number)
|
||
}
|
||
t.Logf("chapter %d stored; chapters_idx count=%d", ref.Number, count)
|
||
})
|
||
}
|
||
})
|
||
|
||
if len(chapterRefs) == 0 {
|
||
t.Fatal("cannot continue: step5 produced no chapter refs")
|
||
}
|
||
|
||
// ── Step 6: Generate TTS audio via scraper HTTP API ───────────────────────
|
||
t.Run("step6_tts_audio", func(t *testing.T) {
|
||
if os.Getenv("SCRAPER_URL") == "" {
|
||
t.Skip("SCRAPER_URL not set — skipping TTS step")
|
||
}
|
||
|
||
voice := envOr("KOKORO_VOICE", "af_bella")
|
||
|
||
for _, ref := range chapterRefs {
|
||
ref := ref
|
||
t.Run(fmt.Sprintf("audio-chapter-%d", ref.Number), func(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||
defer cancel()
|
||
|
||
audioURL := fmt.Sprintf("%s/api/audio/%s/%d", f.scraperURL, testSlug, ref.Number)
|
||
body, _ := json.Marshal(map[string]interface{}{
|
||
"voice": voice,
|
||
"speed": 1.0,
|
||
"max_chars": 200,
|
||
})
|
||
|
||
audioReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, audioURL, bytes.NewReader(body))
|
||
audioReq.Header.Set("Content-Type", "application/json")
|
||
resp, err := http.DefaultClient.Do(audioReq)
|
||
if err != nil {
|
||
t.Fatalf("POST %s: %v", audioURL, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
t.Fatalf("audio generation status=%d body=%s", resp.StatusCode, raw)
|
||
}
|
||
|
||
var audioResp struct {
|
||
URL string `json:"url"`
|
||
Filename string `json:"filename"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&audioResp); err != nil {
|
||
t.Fatalf("decode audio response: %v", err)
|
||
}
|
||
if audioResp.URL == "" {
|
||
t.Error("audio response has empty url field")
|
||
}
|
||
if audioResp.Filename == "" {
|
||
t.Error("audio response has empty filename field")
|
||
}
|
||
t.Logf("chapter %d audio: url=%s filename=%s", ref.Number, audioResp.URL, audioResp.Filename)
|
||
|
||
// Verify audio_cache entry exists in PocketBase.
|
||
pbCtx, pbCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer pbCancel()
|
||
|
||
cacheKey := fmt.Sprintf("%s/%d/%s/1.00", testSlug, ref.Number, voice)
|
||
filename, found := f.hs.GetAudioCache(pbCtx, cacheKey)
|
||
if !found {
|
||
t.Errorf("audio_cache entry not found for key=%q", cacheKey)
|
||
} else {
|
||
t.Logf("audio_cache[%q] = %q", cacheKey, filename)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
// ── Step 7: Presigned URLs ────────────────────────────────────────────────
|
||
t.Run("step7_presigned_urls", func(t *testing.T) {
|
||
if os.Getenv("SCRAPER_URL") == "" {
|
||
t.Skip("SCRAPER_URL not set — skipping presign step")
|
||
}
|
||
|
||
// Give the background MinIO upload goroutines (launched by handleAudioGenerate)
|
||
// a moment to complete before we attempt to access the presigned URLs.
|
||
time.Sleep(5 * time.Second)
|
||
|
||
voice := envOr("KOKORO_VOICE", "af_bella")
|
||
|
||
for _, ref := range chapterRefs {
|
||
ref := ref
|
||
t.Run(fmt.Sprintf("presign-chapter-%d", ref.Number), func(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
// Chapter markdown presign.
|
||
chPresignURL := fmt.Sprintf("%s/api/presign/chapter/%s/%d",
|
||
f.scraperURL, testSlug, ref.Number)
|
||
chPresigned := fetchPresignedURL(t, ctx, chPresignURL, "chapter presign")
|
||
if chPresigned != "" {
|
||
assertURLAccessible(t, ctx, chPresigned, fmt.Sprintf("chapter %d presigned URL", ref.Number))
|
||
}
|
||
|
||
// Audio presign — poll with retries to allow background MinIO upload to finish.
|
||
auPresignURL := fmt.Sprintf("%s/api/presign/audio/%s/%d?voice=%s&speed=1.0",
|
||
f.scraperURL, testSlug, ref.Number, voice)
|
||
auPresigned := fetchPresignedURL(t, ctx, auPresignURL, "audio presign")
|
||
if auPresigned != "" {
|
||
assertURLAccessibleWithRetry(t, ctx, auPresigned, fmt.Sprintf("chapter %d audio presigned URL", ref.Number), 6, 5*time.Second)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// ─── PocketBase admin helpers ─────────────────────────────────────────────────
|
||
|
||
// pbAuthToken obtains a PocketBase superuser JWT.
|
||
func pbAuthToken(ctx context.Context, f *e2eFixture) (string, error) {
|
||
body, _ := json.Marshal(map[string]string{
|
||
"identity": f.pbEmail,
|
||
"password": f.pbPassword,
|
||
})
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||
f.pbBaseURL+"/api/collections/_superusers/auth-with-password",
|
||
bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("pbAuthToken: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
b, _ := io.ReadAll(resp.Body)
|
||
return "", fmt.Errorf("pbAuthToken status %d: %s", resp.StatusCode, b)
|
||
}
|
||
var result struct {
|
||
Token string `json:"token"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return "", fmt.Errorf("pbAuthToken decode: %w", err)
|
||
}
|
||
return result.Token, nil
|
||
}
|
||
|
||
// createAppUser inserts a record into app_users via PocketBase admin API.
|
||
func createAppUser(ctx context.Context, f *e2eFixture, username, passwordHash, role string) error {
|
||
tok, err := pbAuthToken(ctx, f)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
payload, _ := json.Marshal(map[string]interface{}{
|
||
"username": username,
|
||
"password_hash": passwordHash,
|
||
"role": role,
|
||
"created": time.Now().UTC().Format(time.RFC3339),
|
||
})
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||
f.pbBaseURL+"/api/collections/app_users/records",
|
||
bytes.NewReader(payload))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", tok)
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("createAppUser: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||
b, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("createAppUser status %d: %s", resp.StatusCode, b)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// getAppUserByUsername fetches an app_users record by username.
|
||
// Returns nil, nil when not found.
|
||
func getAppUserByUsername(ctx context.Context, f *e2eFixture, username string) (map[string]interface{}, error) {
|
||
tok, err := pbAuthToken(ctx, f)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=1",
|
||
f.pbBaseURL, username)
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Authorization", tok)
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("getAppUserByUsername: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
var result struct {
|
||
Items []map[string]interface{} `json:"items"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return nil, fmt.Errorf("getAppUserByUsername decode: %w", err)
|
||
}
|
||
if len(result.Items) == 0 {
|
||
return nil, nil
|
||
}
|
||
return result.Items[0], nil
|
||
}
|
||
|
||
// deleteAppUser removes app_users records matching username.
|
||
func deleteAppUser(t *testing.T, f *e2eFixture, ctx context.Context, username string) {
|
||
t.Helper()
|
||
tok, err := pbAuthToken(ctx, f)
|
||
if err != nil {
|
||
t.Logf("deleteAppUser: pbAuthToken error: %v", err)
|
||
return
|
||
}
|
||
// List matching records.
|
||
url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=10",
|
||
f.pbBaseURL, username)
|
||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
req.Header.Set("Authorization", tok)
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Logf("deleteAppUser list error: %v", err)
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
var result struct {
|
||
Items []map[string]interface{} `json:"items"`
|
||
}
|
||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||
for _, item := range result.Items {
|
||
id, _ := item["id"].(string)
|
||
delURL := fmt.Sprintf("%s/api/collections/app_users/records/%s", f.pbBaseURL, id)
|
||
delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil)
|
||
delReq.Header.Set("Authorization", tok)
|
||
delResp, _ := http.DefaultClient.Do(delReq)
|
||
if delResp != nil {
|
||
delResp.Body.Close()
|
||
}
|
||
}
|
||
}
|
||
|
||
// cleanupTestData removes all PocketBase + MinIO data for the given slug.
|
||
func cleanupTestData(t *testing.T, f *e2eFixture, ctx context.Context, slug string) {
|
||
t.Helper()
|
||
tok, err := pbAuthToken(ctx, f)
|
||
if err != nil {
|
||
t.Logf("cleanupTestData: pbAuthToken error: %v", err)
|
||
return
|
||
}
|
||
pbDelete := func(collection, filter string) {
|
||
listURL := fmt.Sprintf("%s/api/collections/%s/records?filter=%s&perPage=500",
|
||
f.pbBaseURL, collection, filter)
|
||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
|
||
req.Header.Set("Authorization", tok)
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Logf("cleanupTestData list %s error: %v", collection, err)
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
var result struct {
|
||
Items []map[string]interface{} `json:"items"`
|
||
}
|
||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||
for _, item := range result.Items {
|
||
id, _ := item["id"].(string)
|
||
delURL := fmt.Sprintf("%s/api/collections/%s/records/%s", f.pbBaseURL, collection, id)
|
||
delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil)
|
||
delReq.Header.Set("Authorization", tok)
|
||
delResp, _ := http.DefaultClient.Do(delReq)
|
||
if delResp != nil {
|
||
delResp.Body.Close()
|
||
}
|
||
}
|
||
}
|
||
slugFilter := fmt.Sprintf("slug%%3D%%22%s%%22", slug)
|
||
ckFilter := fmt.Sprintf("cache_key%%7E%%22%s%%2F%%22", slug) // cache_key ~ "slug/"
|
||
pbDelete("books", slugFilter)
|
||
pbDelete("chapters_idx", slugFilter)
|
||
pbDelete("audio_cache", ckFilter)
|
||
t.Logf("cleanup complete for slug=%q", slug)
|
||
}
|
||
|
||
// ─── HTTP assertion helpers ───────────────────────────────────────────────────
|
||
|
||
// checkHTTP asserts that a GET to url returns 2xx within the context deadline.
|
||
func checkHTTP(t *testing.T, ctx context.Context, url, name string) {
|
||
t.Helper()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
t.Errorf("%s health check: build request: %v", name, err)
|
||
return
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Errorf("%s health check failed: %v", name, err)
|
||
return
|
||
}
|
||
resp.Body.Close()
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
t.Errorf("%s health check: status %d, want 2xx", name, resp.StatusCode)
|
||
return
|
||
}
|
||
t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode)
|
||
}
|
||
|
||
// waitForHTTP retries GET url until a 2xx is received or timeout is reached.
|
||
func waitForHTTP(t *testing.T, ctx context.Context, url, name string, timeout time.Duration) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(timeout)
|
||
var lastErr error
|
||
for time.Now().Before(deadline) {
|
||
select {
|
||
case <-ctx.Done():
|
||
t.Errorf("%s: context cancelled while waiting for health", name)
|
||
return
|
||
default:
|
||
}
|
||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||
resp.Body.Close()
|
||
t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode)
|
||
return
|
||
}
|
||
if resp != nil {
|
||
resp.Body.Close()
|
||
lastErr = fmt.Errorf("status %d", resp.StatusCode)
|
||
} else {
|
||
lastErr = err
|
||
}
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
t.Errorf("%s not healthy after %s: %v", name, timeout, lastErr)
|
||
}
|
||
|
||
// fetchPresignedURL calls the presign endpoint and returns the presigned URL.
|
||
// It logs and returns "" on failure (non-fatal) so the caller can decide.
|
||
func fetchPresignedURL(t *testing.T, ctx context.Context, presignEndpoint, label string) string {
|
||
t.Helper()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, presignEndpoint, nil)
|
||
if err != nil {
|
||
t.Errorf("fetchPresignedURL %s: %v", label, err)
|
||
return ""
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Errorf("fetchPresignedURL %s: %v", label, err)
|
||
return ""
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
b, _ := io.ReadAll(resp.Body)
|
||
t.Errorf("fetchPresignedURL %s: status %d body=%s", label, resp.StatusCode, b)
|
||
return ""
|
||
}
|
||
var body struct {
|
||
URL string `json:"url"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||
t.Errorf("fetchPresignedURL %s decode: %v", label, err)
|
||
return ""
|
||
}
|
||
if body.URL == "" {
|
||
t.Errorf("fetchPresignedURL %s: empty url in response", label)
|
||
return ""
|
||
}
|
||
t.Logf("%s presigned URL: %s", label, body.URL)
|
||
return body.URL
|
||
}
|
||
|
||
// assertURLAccessible does a GET to url and asserts HTTP 200.
|
||
func assertURLAccessible(t *testing.T, ctx context.Context, url, label string) {
|
||
t.Helper()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
t.Errorf("%s: build request: %v", label, err)
|
||
return
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Errorf("%s: GET error: %v", label, err)
|
||
return
|
||
}
|
||
resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Errorf("%s: status %d, want 200", label, resp.StatusCode)
|
||
return
|
||
}
|
||
t.Logf("%s: HTTP 200 OK", label)
|
||
}
|
||
|
||
// assertURLAccessibleWithRetry retries GET url up to maxAttempts times with
|
||
// interval between attempts, asserting HTTP 200 on any success.
|
||
func assertURLAccessibleWithRetry(t *testing.T, ctx context.Context, url, label string, maxAttempts int, interval time.Duration) {
|
||
t.Helper()
|
||
var lastStatus int
|
||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
t.Errorf("%s: build request: %v", label, err)
|
||
return
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Logf("%s: attempt %d GET error: %v", label, attempt, err)
|
||
} else {
|
||
resp.Body.Close()
|
||
lastStatus = resp.StatusCode
|
||
if resp.StatusCode == http.StatusOK {
|
||
t.Logf("%s: HTTP 200 OK (attempt %d)", label, attempt)
|
||
return
|
||
}
|
||
t.Logf("%s: attempt %d status %d", label, attempt, resp.StatusCode)
|
||
}
|
||
if attempt < maxAttempts {
|
||
select {
|
||
case <-ctx.Done():
|
||
t.Errorf("%s: context cancelled before success", label)
|
||
return
|
||
case <-time.After(interval):
|
||
}
|
||
}
|
||
}
|
||
t.Errorf("%s: status %d after %d attempts, want 200", label, lastStatus, maxAttempts)
|
||
}
|
||
|
||
// ─── stdlib helpers ───────────────────────────────────────────────────────────
|
||
|
||
func chapterNumbers(refs []scraper.ChapterRef) []int {
|
||
ns := make([]int, len(refs))
|
||
for i, r := range refs {
|
||
ns[i] = r.Number
|
||
}
|
||
return ns
|
||
}
|
||
|
||
// scrapeChapterListPage1 fetches a single chapter-list page URL via Browserless
|
||
// and returns the chapter refs found on that page (no pagination).
|
||
// URL should be: https://novelfire.net/book/{slug}/chapters?page=1
|
||
func scrapeChapterListPage1(ctx context.Context, f *e2eFixture, pageURL string) ([]scraper.ChapterRef, error) {
|
||
return f.sc.ScrapeChapterListPage(ctx, pageURL)
|
||
}
|