Files
libnovel/scraper/internal/novelfire/scraper_test.go
Admin fb6b364382 refactor: audit, split server.go, add unit tests, and fix latent bugs
- 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
2026-03-04 22:14:23 +05:00

296 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package novelfire
import (
"context"
"strings"
"testing"
"github.com/libnovel/scraper/internal/browser"
"github.com/libnovel/scraper/internal/scraper"
)
// ── stub browser client ───────────────────────────────────────────────────────
// stubClient is a BrowserClient that returns a fixed HTML string for every
// GetContent call. ScrapePage and CDPSession are not used by these tests.
type stubClient struct {
html string
}
func (s *stubClient) Strategy() browser.Strategy { return browser.StrategyContent }
func (s *stubClient) GetContent(_ context.Context, _ browser.ContentRequest) (string, error) {
return s.html, nil
}
func (s *stubClient) ScrapePage(_ context.Context, _ browser.ScrapeRequest) (browser.ScrapeResponse, error) {
return browser.ScrapeResponse{}, nil
}
func (s *stubClient) CDPSession(_ context.Context, _ string, _ browser.CDPSessionFunc) error {
return nil
}
// pagedStubClient returns a different HTML response for each successive call.
// Once all pages are exhausted it returns an empty page (no chapter-list),
// simulating the paginated chapter-list endpoint terminating correctly.
type pagedStubClient struct {
pages []string
call int
}
func (c *pagedStubClient) Strategy() browser.Strategy { return browser.StrategyContent }
func (c *pagedStubClient) GetContent(_ context.Context, _ browser.ContentRequest) (string, error) {
if c.call < len(c.pages) {
html := c.pages[c.call]
c.call++
return html, nil
}
// Past the last page — return a page with no chapter-list to stop pagination.
return `<!DOCTYPE html><html><body><div class="no-content"></div></body></html>`, nil
}
func (c *pagedStubClient) ScrapePage(_ context.Context, _ browser.ScrapeRequest) (browser.ScrapeResponse, error) {
return browser.ScrapeResponse{}, nil
}
func (c *pagedStubClient) CDPSession(_ context.Context, _ string, _ browser.CDPSessionFunc) error {
return nil
}
// ── helpers ───────────────────────────────────────────────────────────────────
func newScraper(html string) *Scraper {
return New(&stubClient{html: html}, nil, &stubClient{html: html}, nil, nil)
}
func newPagedScraper(pages ...string) *Scraper {
urlClient := &pagedStubClient{pages: pages}
return New(&stubClient{}, nil, urlClient, nil, nil)
}
// ── ScrapeChapterText ─────────────────────────────────────────────────────────
func TestScrapeChapterText_ExtractsInnerText(t *testing.T) {
html := `<!DOCTYPE html><html><body>
<div id="content">
<p>It was a dark and stormy night.</p>
<p>The hero stepped forward.</p>
</div>
</body></html>`
s := newScraper(html)
ref := scraper.ChapterRef{Number: 1, Title: "Chapter 1", URL: "https://novelfire.net/book/test-novel/chapter-1"}
ch, err := s.ScrapeChapterText(context.Background(), ref)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch.Ref.Number != 1 {
t.Errorf("expected chapter number 1, got %d", ch.Ref.Number)
}
if !strings.Contains(ch.Text, "dark and stormy") {
t.Errorf("expected chapter text to contain 'dark and stormy', got: %q", ch.Text)
}
if !strings.Contains(ch.Text, "hero stepped forward") {
t.Errorf("expected chapter text to contain 'hero stepped forward', got: %q", ch.Text)
}
}
func TestScrapeChapterText_MissingContainer(t *testing.T) {
html := `<!DOCTYPE html><html><body><div class="other">nothing here</div></body></html>`
s := newScraper(html)
ref := scraper.ChapterRef{Number: 2, Title: "Chapter 2", URL: "https://novelfire.net/book/test-novel/chapter-2"}
_, err := s.ScrapeChapterText(context.Background(), ref)
if err == nil {
t.Fatal("expected an error when #content container is missing, got nil")
}
}
// ── chapterNumberFromURL ──────────────────────────────────────────────────────
func TestChapterNumberFromURL(t *testing.T) {
cases := []struct {
url string
want int
}{
// Standard novelfire pattern.
{"https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-1", 1},
{"https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-26", 26},
{"https://novelfire.net/book/a-dragon-against-the-whole-world/chapter-58", 58},
// Large chapter numbers.
{"https://novelfire.net/book/some-novel/chapter-1000", 1000},
// Path segment with trailing slash.
{"https://novelfire.net/book/some-novel/chapter-5/", 5},
// Slug with title appended after the number (hypothetical future format).
{"https://novelfire.net/book/some-novel/chapter-42-the-battle", 42},
// Unparseable — should return 0 so the caller can fall back.
{"https://novelfire.net/book/some-novel/prologue", 0},
{"https://novelfire.net/book/some-novel/", 0},
{"not-a-url", 0},
}
for _, tc := range cases {
got := chapterNumberFromURL(tc.url)
if got != tc.want {
t.Errorf("chapterNumberFromURL(%q) = %d, want %d", tc.url, got, tc.want)
}
}
}
// ── ScrapeMetadata ────────────────────────────────────────────────────────────
func TestScrapeMetadata_ParsesFields(t *testing.T) {
html := `<!DOCTYPE html><html><body>
<h1 class="novel-title">The Iron Throne</h1>
<span class="author"><a>Jane Doe</a></span>
<figure class="cover"><img src="https://cdn.example.com/cover.jpg"></figure>
<span class="status">Ongoing</span>
<div class="genres"><a>Fantasy</a><a>Action</a></div>
<div class="summary"><p>A sweeping epic set in a magical world.</p></div>
<span class="chapter-count">42 Chapters</span>
</body></html>`
s := newScraper(html)
meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/the-iron-throne")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if meta.Slug != "the-iron-throne" {
t.Errorf("Slug = %q, want %q", meta.Slug, "the-iron-throne")
}
if meta.Title != "The Iron Throne" {
t.Errorf("Title = %q, want %q", meta.Title, "The Iron Throne")
}
if meta.Author != "Jane Doe" {
t.Errorf("Author = %q, want %q", meta.Author, "Jane Doe")
}
if meta.Cover != "https://cdn.example.com/cover.jpg" {
t.Errorf("Cover = %q, want %q", meta.Cover, "https://cdn.example.com/cover.jpg")
}
if meta.Status != "Ongoing" {
t.Errorf("Status = %q, want %q", meta.Status, "Ongoing")
}
if len(meta.Genres) != 2 || meta.Genres[0] != "Fantasy" || meta.Genres[1] != "Action" {
t.Errorf("Genres = %v, want [Fantasy Action]", meta.Genres)
}
if !strings.Contains(meta.Summary, "sweeping epic") {
t.Errorf("Summary = %q, want it to contain 'sweeping epic'", meta.Summary)
}
if meta.TotalChapters != 42 {
t.Errorf("TotalChapters = %d, want 42", meta.TotalChapters)
}
}
func TestScrapeMetadata_RelativeCoverURL(t *testing.T) {
html := `<!DOCTYPE html><html><body>
<h1 class="novel-title">Relative Cover</h1>
<figure class="cover"><img src="/images/cover.jpg"></figure>
</body></html>`
s := newScraper(html)
meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/relative-cover")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Relative cover URL should be resolved against the base domain.
if !strings.HasPrefix(meta.Cover, "https://novelfire.net") {
t.Errorf("Cover = %q, expected it to be resolved to an absolute URL", meta.Cover)
}
}
func TestScrapeMetadata_MissingFields(t *testing.T) {
// Minimal page — everything absent; should succeed without panicking.
html := `<!DOCTYPE html><html><body></body></html>`
s := newScraper(html)
meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/empty-novel")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if meta.Slug != "empty-novel" {
t.Errorf("Slug = %q, want %q", meta.Slug, "empty-novel")
}
if meta.TotalChapters != 0 {
t.Errorf("TotalChapters = %d, want 0 for missing chapter-count", meta.TotalChapters)
}
}
// ── ScrapeChapterList (position vs URL numbering) ─────────────────────────────
// TestScrapeChapterList_NumbersFromURL verifies that when the chapter list HTML
// is served newest-first (as novelfire.net does), chapter numbers are still
// assigned from the URL — not from list position — so that a re-run correctly
// identifies which chapters are already on disk.
func TestScrapeChapterList_NumbersFromURL(t *testing.T) {
// Simulate a newest-first chapter list with 5 chapters on a single page.
// Positions 1..5 correspond to chapters 5,4,3,2,1 in the site HTML.
page1 := `<!DOCTYPE html><html><body>
<ul class="chapter-list">
<li class="chapter-item"><a href="/book/test/chapter-5">Chapter 5</a></li>
<li class="chapter-item"><a href="/book/test/chapter-4">Chapter 4</a></li>
<li class="chapter-item"><a href="/book/test/chapter-3">Chapter 3</a></li>
<li class="chapter-item"><a href="/book/test/chapter-2">Chapter 2</a></li>
<li class="chapter-item"><a href="/book/test/chapter-1">Chapter 1</a></li>
</ul>
</body></html>`
s := newPagedScraper(page1)
refs, err := s.ScrapeChapterList(context.Background(), "https://novelfire.net/book/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(refs) != 5 {
t.Fatalf("expected 5 refs, got %d", len(refs))
}
// With position-based numbering (the old bug), refs[0].Number would be 1
// even though its URL is /chapter-5. With URL-based numbering it must be 5.
wantNumbers := []int{5, 4, 3, 2, 1}
for i, ref := range refs {
if ref.Number != wantNumbers[i] {
t.Errorf("refs[%d].Number = %d, want %d (URL: %s)", i, ref.Number, wantNumbers[i], ref.URL)
}
}
}
// TestScrapeChapterList_Pagination verifies that the scraper correctly follows
// ?page=N pagination and stops when a page returns no chapter items.
func TestScrapeChapterList_Pagination(t *testing.T) {
page1 := `<!DOCTYPE html><html><body>
<ul class="chapter-list">
<li class="chapter-item"><a href="/book/test/chapter-3">Chapter 3</a></li>
<li class="chapter-item"><a href="/book/test/chapter-2">Chapter 2</a></li>
<li class="chapter-item"><a href="/book/test/chapter-1">Chapter 1</a></li>
</ul>
</body></html>`
page2 := `<!DOCTYPE html><html><body>
<ul class="chapter-list">
<li class="chapter-item"><a href="/book/test/chapter-6">Chapter 6</a></li>
<li class="chapter-item"><a href="/book/test/chapter-5">Chapter 5</a></li>
<li class="chapter-item"><a href="/book/test/chapter-4">Chapter 4</a></li>
</ul>
</body></html>`
// page3 is omitted — pagedStubClient will return empty page to stop pagination.
s := newPagedScraper(page1, page2)
refs, err := s.ScrapeChapterList(context.Background(), "https://novelfire.net/book/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(refs) != 6 {
t.Fatalf("expected 6 refs (3 per page × 2 pages), got %d", len(refs))
}
wantNumbers := []int{3, 2, 1, 6, 5, 4}
for i, ref := range refs {
if ref.Number != wantNumbers[i] {
t.Errorf("refs[%d].Number = %d, want %d (URL: %s)", i, ref.Number, wantNumbers[i], ref.URL)
}
}
}