Files
libnovel/scraper/internal/novelfire/ranking_test.go
Admin b8d4d94b18 refactor(ranking): replace blob cache with per-item PocketBase storage
- Replace SetRanking/GetRanking/SetRankingPageHTML/GetRankingPageHTML blob methods
  with WriteRankingItem/ReadRankingItems/RankingFreshEnough per-item operations
- Add 24h staleness gate in ScrapeRanking to skip re-scraping fresh data
- Add GET /api/ranking endpoint returning []RankingItem sorted by rank
- Remove RankingPageCacher interface and rankingCacheAdapter adapter
- Update integration tests to use new per-item upsert semantics
- Include e2e test suite (scraper/internal/e2e/)
2026-03-03 19:37:49 +05:00

209 lines
7.3 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"
"os"
"path/filepath"
"testing"
"github.com/libnovel/scraper/internal/scraper"
"github.com/libnovel/scraper/internal/writer"
)
// rankingPage1HTML is a realistic mock of the popular genre listing page
// (novelfire.net/genre-all/sort-popular/status-all/all-novel?page=1).
// It uses the real novelfire.net DOM: <li class="novel-item"> cards with
// <h4 class="novel-title"> and a rel="next" pagination link.
func rankingPage1HTML() string {
return `<!DOCTYPE html>
<html><body>
<ul class="list-novel">
<li class="novel-item">
<a title="The Iron Throne" href="/book/the-iron-throne">
<figure class="novel-cover"><img class="lazy" src="data:image/gif;base64,R0lG" data-src="/covers/iron-throne.jpg" alt="The Iron Throne"></figure>
<h4 class="novel-title text2row">The Iron Throne</h4>
</a>
<div class="novel-stats"><i class="icon-book-open"></i> 500 Chapters</div>
</li>
<li class="novel-item">
<a title="Shadow Mage" href="/book/shadow-mage">
<figure class="novel-cover"><img class="lazy" src="data:image/gif;base64,R0lG" data-src="/covers/shadow-mage.jpg" alt="Shadow Mage"></figure>
<h4 class="novel-title text2row">Shadow Mage</h4>
</a>
<div class="novel-stats"><i class="icon-book-open"></i> 200 Chapters</div>
</li>
</ul>
<ul class="pagination">
<li class="page-item active"><span class="page-link">1</span></li>
<li class="page-item"><a class="page-link" href="/genre-all/sort-popular/status-all/all-novel?page=2" rel="next" aria-label="Next"></a></li>
</ul>
</body></html>`
}
func rankingPage2HTML() string {
return `<!DOCTYPE html>
<html><body>
<ul class="list-novel">
<li class="novel-item">
<a title="Void Hunter" href="/book/void-hunter">
<figure class="novel-cover"><img class="lazy" src="data:image/gif;base64,R0lG" data-src="/covers/void-hunter.jpg" alt="Void Hunter"></figure>
<h4 class="novel-title text2row">Void Hunter</h4>
</a>
<div class="novel-stats"><i class="icon-book-open"></i> 100 Chapters</div>
</li>
</ul>
<!-- no rel="next" link → last page -->
<ul class="pagination">
<li class="page-item"><a class="page-link" href="?page=1" rel="prev"></a></li>
<li class="page-item active"><span class="page-link">2</span></li>
</ul>
</body></html>`
}
// drainRanking collects all entries from a ScrapeRanking call without
// deadlocking. It uses "for A != nil || B != nil" — nil channels are never
// selected, so setting one to nil effectively removes it from the select.
func drainRanking(t *testing.T, entryCh <-chan scraper.BookMeta, errCh <-chan error) []scraper.BookMeta {
t.Helper()
var entries []scraper.BookMeta
for entryCh != nil || errCh != nil {
select {
case meta, ok := <-entryCh:
if !ok {
entryCh = nil
} else {
entries = append(entries, meta)
}
case err, ok := <-errCh:
if !ok {
errCh = nil
} else if err != nil {
t.Fatalf("unexpected scrape error: %v", err)
}
}
}
return entries
}
// TestScrapeRanking_SinglePage verifies a single page is parsed into entries
// with sequential Ranking numbers using a stub client.
// ScrapeRanking uses s.client (the main client, not urlClient) because the
// ranking page is fully server-rendered.
func TestScrapeRanking_SinglePage(t *testing.T) {
// newScraper passes the stub as s.client — exactly what ScrapeRanking uses.
s := newScraper(rankingPage1HTML())
entryCh, errCh := s.ScrapeRanking(context.Background(), 1)
entries := drainRanking(t, entryCh, errCh)
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].Ranking != 1 || entries[0].Title != "The Iron Throne" {
t.Errorf("entry[0]: got rank=%d title=%q, want rank=1 title=%q",
entries[0].Ranking, entries[0].Title, "The Iron Throne")
}
if entries[1].Ranking != 2 || entries[1].Title != "Shadow Mage" {
t.Errorf("entry[1]: got rank=%d title=%q, want rank=2 title=%q",
entries[1].Ranking, entries[1].Title, "Shadow Mage")
}
}
// TestScrapeRanking_MultiPage verifies pagination across two pages yields
// contiguous rank numbers (1, 2, 3).
func TestScrapeRanking_MultiPage(t *testing.T) {
// Use pagedStubClient for s.client so each GetContent call returns the
// next page. ScrapeRanking now calls s.client directly.
urlClient := &pagedStubClient{pages: []string{rankingPage1HTML(), rankingPage2HTML()}}
s := New(urlClient, nil, nil, nil) // nil cache — no disk I/O in tests
entryCh, errCh := s.ScrapeRanking(context.Background(), 0) // 0 = all pages
entries := drainRanking(t, entryCh, errCh)
if len(entries) != 3 {
t.Fatalf("expected 3 entries across 2 pages, got %d", len(entries))
}
want := []struct {
rank int
title string
}{
{1, "The Iron Throne"},
{2, "Shadow Mage"},
{3, "Void Hunter"},
}
for i, w := range want {
if entries[i].Ranking != w.rank || entries[i].Title != w.title {
t.Errorf("entry[%d]: got rank=%d title=%q, want rank=%d title=%q",
i, entries[i].Ranking, entries[i].Title, w.rank, w.title)
}
}
}
// TestScrapeRanking_EmptyPage verifies that a page with no .novel-item
// cards produces zero entries and closes channels cleanly (no deadlock).
func TestScrapeRanking_EmptyPage(t *testing.T) {
s := newScraper(`<!DOCTYPE html><html><body><div class="no-rankings"></div></body></html>`)
entryCh, errCh := s.ScrapeRanking(context.Background(), 1)
entries := drainRanking(t, entryCh, errCh)
if len(entries) != 0 {
t.Errorf("expected 0 entries for empty page, got %d", len(entries))
}
}
// TestWriteRanking_RoundTrip verifies WriteRanking → ReadRankingItems
// faithfully reconstructs the original slice.
func TestWriteRanking_RoundTrip(t *testing.T) {
dir := t.TempDir()
w := writer.New(dir)
items := []writer.RankingItem{
{Rank: 1, Slug: "the-iron-throne", Title: "The Iron Throne", Status: "Ongoing",
Genres: []string{"Fantasy", "Action"}, SourceURL: "https://novelfire.net/book/the-iron-throne"},
{Rank: 2, Slug: "shadow-mage", Title: "Shadow Mage", Status: "Completed",
Genres: []string{"Magic"}, SourceURL: "https://novelfire.net/book/shadow-mage"},
}
if err := w.WriteRanking(items); err != nil {
t.Fatalf("WriteRanking failed: %v", err)
}
rankingFile := filepath.Join(dir, "ranking.json")
if _, err := os.Stat(rankingFile); err != nil {
t.Fatalf("ranking.json not created: %v", err)
}
got, err := w.ReadRankingItems()
if err != nil {
t.Fatalf("ReadRankingItems failed: %v", err)
}
if len(got) != len(items) {
t.Fatalf("expected %d items, got %d", len(items), len(got))
}
for i, want := range items {
if got[i].Rank != want.Rank {
t.Errorf("item[%d].Rank = %d, want %d", i, got[i].Rank, want.Rank)
}
if got[i].Slug != want.Slug {
t.Errorf("item[%d].Slug = %q, want %q", i, got[i].Slug, want.Slug)
}
if got[i].Title != want.Title {
t.Errorf("item[%d].Title = %q, want %q", i, got[i].Title, want.Title)
}
if got[i].Status != want.Status {
t.Errorf("item[%d].Status = %q, want %q", i, got[i].Status, want.Status)
}
if len(got[i].Genres) != len(want.Genres) {
t.Errorf("item[%d].Genres len = %d, want %d", i, len(got[i].Genres), len(want.Genres))
} else {
for j, g := range want.Genres {
if got[i].Genres[j] != g {
t.Errorf("item[%d].Genres[%d] = %q, want %q", i, j, got[i].Genres[j], g)
}
}
}
if got[i].SourceURL != want.SourceURL {
t.Errorf("item[%d].SourceURL = %q, want %q", i, got[i].SourceURL, want.SourceURL)
}
}
}