package app
import (
"context"
"encoding/json"
"html"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func rankingFixture(board rankingBoard, page int, rows []map[string]any) string {
key := "rank_" + board.path + "/page"
loader, _ := json.Marshal(map[string]any{"loaderData": map[string]any{key: map[string]any{"rankKey": board.upstreamKey, "pageNum": page, "updatedText": "9月13日已更新", "content": map[string]any{}}}})
args, _ := json.Marshal([]any{key, "content", map[string]any{"isSuccess": true, "rankList": rows, "pagination": map[string]int{"pageNum": page, "totalPages": 2}}})
return ""
}
func rankingRows(page int) []map[string]any {
return []map[string]any{
{"id": "7681903520873729048", "seriesId": "7681903520873729048", "rank": (page-1)*20 + 1, "title": "甲 & <乙> \"丙\"", "heatText": "1.2亿热度", "episodeVids": []string{"1111111111111111111", "2222222222222222222"}},
{"id": "7680883072278989849", "seriesId": "7680883072278989849", "rank": (page-1)*20 + 3, "title": "另一部", "heatText": "9000万热度"},
}
}
func TestHongguoRankingStreamAndValidation(t *testing.T) {
board, _ := findRankingBoard("hongguo-hot")
result, err := parseHongguoRanking(rankingFixture(board, 2, rankingRows(2)), board, 2)
if err != nil {
t.Fatal(err)
}
if len(result.Items) != 2 || result.Items[0].Rank != 21 || result.Items[1].Rank != 23 || result.HasMore || result.Items[0].Drama.Title != "甲 & <乙> \"丙\"" {
t.Fatalf("streamed data lost its rank, title or pagination: %+v", result)
}
if result.Items[0].Drama.Views != "" || result.Items[0].Drama.Heat != "1.2亿热度" || result.Items[0].Drama.Cover != nil {
t.Fatal("heat must stay separate from views, and ranking metadata must have no images")
}
for _, test := range []struct {
name string
change func([]map[string]any)
}{
{"duplicate ID", func(rows []map[string]any) { rows[1]["id"], rows[1]["seriesId"] = rows[0]["id"], rows[0]["seriesId"] }},
{"duplicate rank", func(rows []map[string]any) { rows[1]["rank"] = 1 }},
{"wrong page ranks", func(rows []map[string]any) { rows[0]["rank"] = 21 }},
{"mismatched ID", func(rows []map[string]any) { rows[0]["id"] = "7777777777777777777" }},
{"missing title", func(rows []map[string]any) { rows[0]["title"] = "" }},
} {
t.Run(test.name, func(t *testing.T) {
rows := rankingRows(1)
test.change(rows)
if _, err := parseHongguoRanking(rankingFixture(board, 1, rows), board, 1); err == nil {
t.Fatal("accepted invalid upstream ranks")
}
})
}
other, _ := findRankingBoard("hongguo-ai")
if _, err := parseHongguoRanking(rankingFixture(other, 1, rankingRows(1)), board, 1); err == nil {
t.Fatal("accepted a different board")
}
if _, err := parseHongguoRanking(rankingFixture(board, 1, rankingRows(1)), board, 2); err == nil {
t.Fatal("accepted a repeated first page")
}
if _, err := parseHongguoRanking(rankingFixture(board, 1, []map[string]any{}), board, 1); err == nil {
t.Fatal("empty page with hasMore must be treated as a failure")
}
}
func TestHuangdouRankingPreservesOrderAndMetrics(t *testing.T) {
rows := []any{
map[string]any{"id": "rp_first", "name": "低热度先返回", "hot_rate": "20", "click": "100"},
map[string]any{"id": "second", "name": "高热度后返回", "hot_rate": "1000", "click": "2"},
}
result, err := parseHuangdouRanking(map[string]any{"data": map[string]any{"list": rows}}, 2)
if err != nil {
t.Fatal(err)
}
if len(result.Items) != 2 || result.Items[0].Rank != 21 || result.Items[0].Drama.ID != "huangdou:first" || result.Items[0].Drama.Heat != "20" || result.Items[0].Drama.Views != "100次播放" || result.HasMore {
t.Fatalf("wrong rank or source metric: %+v", result)
}
if _, err := parseHuangdouRanking(map[string]any{"error": "not a list"}, 1); err == nil {
t.Fatal("an upstream error cannot become an empty successful board")
}
result, err = parseHuangdouRanking(map[string]any{"list": []any{}}, 1)
if err != nil || len(result.Items) != 0 || result.HasMore {
t.Fatal("explicitly empty lists must be supported")
}
}
func TestHuangguoRankingStructuredData(t *testing.T) {
board, _ := findRankingBoard("huangguo-potential")
body := ``
result, err := parseHuangguoRanking(body, board)
if err != nil || len(result.Items) != 2 || result.Items[1].Rank != 3 || result.Items[0].Drama.ID != "huangguoai:12" || result.HasMore {
t.Fatalf("could not preserve structured site ranks: %+v %v", result, err)
}
other, _ := findRankingBoard("huangguo-hot")
if _, err := parseHuangguoRanking(body, other); err == nil {
t.Fatal("accepted a different chart's structured data")
}
if _, err := parseHuangguoRanking(strings.ReplaceAll(body, "/detail/34/", "/detail/12/"), board); err == nil {
t.Fatal("accepted duplicate IDs")
}
}
func TestHongguoReleaseLabelsUseSourceMeaning(t *testing.T) {
now := time.Date(2026, 9, 12, 17, 30, 0, 0, time.UTC)
for _, test := range []struct{ label, expected string }{
{"今日上新", "2026-09-13"}, {"昨日上新", "2026-09-12"}, {"2026-08-21上新", "2026-08-21"},
{"新剧", ""}, {"2026-02-31上新", ""}, {"都市", ""},
} {
row := map[string]any{"sub_title_list": []any{map[string]any{"content": test.label}}}
if got := hongguoOnlineDate(row, now); got != test.expected {
t.Errorf("%q: got %q, want %q", test.label, got, test.expected)
}
}
}
type rankingTransport func(*http.Request) (*http.Response, error)
func (transport rankingTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport(request)
}
func rankingTestDownloader(t *testing.T, transport rankingTransport) *Downloader {
t.Helper()
cfg := defaultConfig()
cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), "direct", 1
d := NewDownloader(cfg)
d.client = &http.Client{Transport: transport}
d.limiter = newRequestLimiter(8, time.Nanosecond)
return d
}
func rankingHTTPResponse(request *http.Request, status int, body string) *http.Response {
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{"Content-Type": {"text/html; charset=utf-8"}}, Request: request}
}
func TestRankingCacheAndStaleRecovery(t *testing.T) {
board, _ := findRankingBoard("hongguo-hot")
var calls atomic.Int32
var fail atomic.Bool
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
calls.Add(1)
if fail.Load() {
return rankingHTTPResponse(request, 503, "temporarily unavailable"), nil
}
page, _ := strconv.Atoi(request.URL.Query().Get("page"))
return rankingHTTPResponse(request, 200, rankingFixture(board, page, rankingRows(page))), nil
})
ctx := context.Background()
first, err := d.loadRankingPage(ctx, board, 1, false)
if err != nil {
t.Fatal(err)
}
first.Items[0].Rank = 999
again, err := d.loadRankingPage(ctx, board, 1, false)
if err != nil || again.Items[0].Rank != 1 || calls.Load() != 1 {
t.Fatal("a fresh page was not safely reused")
}
if _, err = d.loadRankingPage(ctx, board, 2, false); err != nil {
t.Fatal(err)
}
if _, err = d.loadRankingPage(ctx, board, 1, true); err != nil {
t.Fatal(err)
}
if _, found := d.rankings.pages[board.ID+":2"]; found {
t.Fatal("refresh must invalidate older continuation pages")
}
previous := d.rankings.pages[board.ID+":1"].FetchedAt
fail.Store(true)
stale, err := d.loadRankingPage(ctx, board, 1, true)
if err != nil || !stale.Stale || stale.Warning == "" || !stale.FetchedAt.Equal(previous) {
t.Fatalf("last valid page must retain its original freshness: %+v %v", stale, err)
}
fail.Store(false)
fresh, err := d.loadRankingPage(ctx, board, 1, true)
if err != nil || fresh.Stale || fresh.Warning != "" {
t.Fatal("a later successful refresh must clear the stale state")
}
}
func TestRankingConcurrentRequestsShareFetch(t *testing.T) {
board, _ := findRankingBoard("hongguo-hot")
var calls atomic.Int32
started, release := make(chan struct{}), make(chan struct{})
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
if calls.Add(1) == 1 {
close(started)
}
<-release
return rankingHTTPResponse(request, 200, rankingFixture(board, 1, rankingRows(1))), nil
})
var group sync.WaitGroup
for index := 0; index < 8; index++ {
group.Add(1)
go func() {
defer group.Done()
result, err := d.loadRankingPage(context.Background(), board, 1, false)
if err != nil || len(result.Items) != 2 {
t.Errorf("concurrent request failed: %v", err)
}
}()
}
<-started
close(release)
group.Wait()
if calls.Load() != 1 {
t.Fatalf("got %d upstream requests, want one", calls.Load())
}
}
func TestRankingHandlerRegistersPlayableMetadataOnce(t *testing.T) {
board, _ := findRankingBoard("hongguo-hot")
var calls atomic.Int32
release := make(chan struct{})
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
if request.URL.Path == "/rank/hot-drama" {
calls.Add(1)
return rankingHTTPResponse(request, 200, rankingFixture(board, 1, rankingRows(1))), nil
}
select {
case <-release:
case <-request.Context().Done():
return nil, request.Context().Err()
}
switch request.URL.Path {
case "/detail":
if request.URL.Query().Get("series_id") != "7680883072278989849" {
t.Error("ranking automatically enrolled an existing cache entry")
}
return rankingHTTPResponse(request, 200, sortDetailFixture("7680883072278989849", "1773662280")), nil
case "/novel/player/video_detail/v1/":
return rankingHTTPResponse(request, 200, `{"data":{"video_data":{"series_id_str":"7680883072278989849","hot_score":90000001,"series_play_cnt":10019}}}`), nil
default:
t.Errorf("unexpected ranking metadata request: %s", request.URL.Path)
return rankingHTTPResponse(request, 404, ""), nil
}
})
a := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{{ID: "hongguo:7681903520873729048", Source: sourceHongguo, Title: "旧标题", OnlineDate: "2026-09-01"}, {ID: "huangdou:existing", Source: sourceHuangdou, Title: "保留其他站源"}}}
t.Cleanup(a.stopSortMetadata)
for _, test := range []struct {
method, path string
status int
}{
{"GET", "/api/ui/rankings", 200}, {"POST", "/api/ui/rankings", 405},
{"GET", "/api/ui/rankings?board=missing", 400}, {"GET", "/api/ui/rankings?board=hongguo-hot&page=0", 400},
{"GET", "/api/ui/rankings?board=hongguo-hot&page=1.5", 400}, {"GET", "/api/ui/rankings?board=huangguo-hot&page=2", 400},
} {
writer := httptest.NewRecorder()
a.handleRankings(writer, httptest.NewRequest(test.method, test.path, nil))
if writer.Code != test.status {
t.Fatalf("%s returned %d, want %d", test.path, writer.Code, test.status)
}
}
if calls.Load() != 0 {
t.Fatal("listing available boards and rejecting invalid inputs must not fetch upstream")
}
var firstRevision uint64
for index := 0; index < 2; index++ {
writer := httptest.NewRecorder()
a.handleRankings(writer, httptest.NewRequest("GET", "/api/ui/rankings?board=hongguo-hot", nil))
if writer.Code != 200 {
t.Fatal(writer.Body.String())
}
if index == 0 {
firstRevision = a.libraryRevision
}
}
if len(a.dramas) != 3 || a.libraryRevision != firstRevision || !a.librarySaved || calls.Load() != 1 {
t.Fatal("rankings must merge into the existing library once, including previously unseen dramas")
}
for _, drama := range a.dramas {
if drama.ID == "hongguo:7681903520873729048" && (drama.OnlineDate != "2026-09-01" || drama.Heat != "1.2亿热度") {
t.Fatal("ranking enrichment lost prior metadata")
}
}
close(release)
waitSortMetadataQueue(t, a)
a.mu.Lock()
defer a.mu.Unlock()
for _, drama := range a.dramas {
if drama.ID == "hongguo:7680883072278989849" && (drama.OnlineDate != "2026-03-16" || drama.Heat != "90000001" || drama.Views != "10019次播放" || drama.SortMetadata.Version != sortMetadataVersion) {
t.Fatal("new ranking entry was not completed asynchronously")
}
}
}
func TestLiveRankingsMetadata(t *testing.T) {
if os.Getenv("JUKU_LIVE_RANKINGS") != "1" {
t.Skip("set JUKU_LIVE_RANKINGS=1 for explicit metadata-only network verification")
}
cfg := defaultConfig()
cfg.dataDir, cfg.OutputDir, cfg.Retries = t.TempDir(), t.TempDir(), 1
d := NewDownloader(cfg)
for _, board := range rankingBoards {
t.Run(board.ID, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
result, err := d.loadRankingPage(ctx, board, 1, true)
if err != nil || len(result.Items) == 0 {
t.Fatalf("no live ranking entries: %v", err)
}
for _, item := range result.Items {
if address := bestDramaCover(item.Drama); address != "" && (item.Drama.Source != sourceHongguo || hongguoCoverAddress(address) != address) {
t.Fatal("ranking metadata must retain only a validated cover address")
}
}
t.Logf("%s: %d entries, ranks %d–%d, more=%v", board.Name, len(result.Items), result.Items[0].Rank, result.Items[len(result.Items)-1].Rank, result.HasMore)
if (board.ID == "hongguo-hot" || board.ID == "huangdou-all") && result.HasMore {
next, err := d.loadRankingPage(ctx, board, 2, true)
if err != nil || len(next.Items) == 0 || next.Items[0].Rank != 21 || next.Items[0].Drama.ID == result.Items[0].Drama.ID {
t.Fatalf("live pagination did not advance: %v", err)
}
t.Logf("page 2: %d entries, first rank %d", len(next.Items), next.Items[0].Rank)
}
})
}
}