211 lines
8.6 KiB
Go
211 lines
8.6 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type hongguoCatalogRequestFixture struct {
|
|
Offset int `json:"offset"`
|
|
Session string `json:"session_id"`
|
|
Scene string `json:"req_scene"`
|
|
}
|
|
|
|
func hongguoCatalogRequestForTest(t *testing.T, request *http.Request) hongguoCatalogRequestFixture {
|
|
t.Helper()
|
|
var payload hongguoCatalogRequestFixture
|
|
if request.URL.Path != "/reading/distribution/category/landpage/v/" || json.NewDecoder(request.Body).Decode(&payload) != nil {
|
|
t.Errorf("unexpected catalog request: %s", request.URL.Path)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func hongguoCatalogResponseForTest(request *http.Request, next any, more any, session string, ids ...string) *http.Response {
|
|
rows := make([]any, 0, len(ids))
|
|
for _, id := range ids {
|
|
rows = append(rows, map[string]any{
|
|
"series_id": id, "series_title": "文字目录样本 " + id,
|
|
"first_visible_time": "1773662280", "hot_score": "20", "series_play_cnt": "0",
|
|
})
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"data": map[string]any{
|
|
"next_offset": next, "has_more": more, "session_id": session, "video_data": rows,
|
|
}})
|
|
return rankingHTTPResponse(request, http.StatusOK, string(body))
|
|
}
|
|
|
|
func TestHongguoCatalogAllowsOverlappingPages(t *testing.T) {
|
|
for _, legacy := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "new cursor", true: "legacy cursor"}[legacy], func(t *testing.T) {
|
|
var offsets []int
|
|
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
|
|
payload := hongguoCatalogRequestForTest(t, request)
|
|
offsets = append(offsets, payload.Offset)
|
|
switch payload.Offset {
|
|
case 0:
|
|
return hongguoCatalogResponseForTest(request, 18, true, "fixture", "700001", "700002"), nil
|
|
case 18:
|
|
return hongguoCatalogResponseForTest(request, 36, true, "fixture", "700003", "700002"), nil
|
|
case 36:
|
|
return hongguoCatalogResponseForTest(request, 54, false, "fixture", "700004"), nil
|
|
default:
|
|
t.Errorf("skipped a page: %d", payload.Offset)
|
|
return rankingHTTPResponse(request, 400, ""), nil
|
|
}
|
|
})
|
|
d.cfg.MaxPagesPerSort = 3
|
|
for _, genre := range hongguoAppGenres[:2] {
|
|
d.hongguoClient().state.Feeds[genre.key] = hongguoCatalogCursor{Initialized: true, Exhausted: true}
|
|
}
|
|
wantOffsets, wantCount := []int{0, 18, 36}, 4
|
|
if legacy {
|
|
d.hongguoClient().state.Feeds["ai_series"] = hongguoCatalogCursor{Offset: 18, LastID: "hongguo:700002", Initialized: true}
|
|
wantOffsets, wantCount = []int{18, 36}, 3
|
|
}
|
|
items, err := d.fetchHongguoAppCatalog(context.WithValue(context.Background(), libraryMoreKey{}, true))
|
|
if err != nil || len(items) != wantCount || !reflect.DeepEqual(offsets, wantOffsets) {
|
|
t.Fatalf("overlap stopped valid pagination: items=%d offsets=%v error=%v", len(items), offsets, err)
|
|
}
|
|
cursor := d.hongguoCatalogSnapshot().Feeds["ai_series"]
|
|
if cursor.Offset != 54 || !cursor.Exhausted {
|
|
t.Fatalf("wrong final checkpoint: %+v", cursor)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHongguoCatalogRecoversStalledPageAfterRestart(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
next any
|
|
more any
|
|
ids []string
|
|
}{
|
|
{name: "unchanged offset", next: 18, more: true, ids: []string{"700003"}},
|
|
{name: "regressed offset", next: 0, more: true, ids: []string{"700003"}},
|
|
{name: "missing offset", more: true, ids: []string{"700003"}},
|
|
{name: "missing continuation flag", next: 36, ids: []string{"700003"}},
|
|
{name: "empty page", next: 36, more: true},
|
|
{name: "same page", next: 36, more: true, ids: []string{"700001", "700002"}},
|
|
{name: "same page reordered", next: 36, more: true, ids: []string{"700002", "700001"}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var calls []hongguoCatalogRequestFixture
|
|
transport := func(request *http.Request) (*http.Response, error) {
|
|
payload := hongguoCatalogRequestForTest(t, request)
|
|
if payload.Scene != "ai_series" {
|
|
return hongguoCatalogResponseForTest(request, 0, false, ""), nil
|
|
}
|
|
calls = append(calls, payload)
|
|
switch len(calls) {
|
|
case 1:
|
|
return hongguoCatalogResponseForTest(request, 18, true, "expired-session", "700001", "700002"), nil
|
|
case 2:
|
|
return hongguoCatalogResponseForTest(request, test.next, test.more, "expired-session", test.ids...), nil
|
|
case 3:
|
|
return hongguoCatalogResponseForTest(request, 36, true, "new-session", "700004"), nil
|
|
default:
|
|
t.Error("retried more than once")
|
|
return rankingHTTPResponse(request, 400, ""), nil
|
|
}
|
|
}
|
|
first := rankingTestDownloader(t, transport)
|
|
first.cfg.MaxPagesPerSort = 1
|
|
initial, err := first.fetchHongguoAppCatalog(context.Background())
|
|
if err != nil || len(initial) != 2 {
|
|
t.Fatalf("initial batch failed: %v", err)
|
|
}
|
|
if err := writeLibraryCache(first.cfg.dataDirectory(), libraryCache{Dramas: initial, HongguoApp: first.hongguoCatalogSnapshot()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cache, err := readLibraryCache(first.cfg.dataDirectory())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second := rankingTestDownloader(t, transport)
|
|
second.cfg.MaxPagesPerSort = 1
|
|
second.restoreHongguoCatalog(cache.HongguoApp)
|
|
items, err := second.fetchHongguoAppCatalog(context.WithValue(context.Background(), libraryMoreKey{}, true))
|
|
if err != nil || len(calls) != 3 {
|
|
t.Fatalf("did not recover at the saved position: calls=%+v error=%v", calls, err)
|
|
}
|
|
if calls[1].Offset != 18 || calls[1].Session != "expired-session" || calls[2].Offset != 18 || calls[2].Session != "" {
|
|
t.Fatalf("recovery skipped a page or reused the bad session: %+v", calls)
|
|
}
|
|
cursor := second.hongguoCatalogSnapshot().Feeds["ai_series"]
|
|
if cursor.Offset != 36 || cursor.SessionID != "new-session" || cursor.Exhausted {
|
|
t.Fatalf("recovery did not save the valid checkpoint: %+v", cursor)
|
|
}
|
|
ids := map[string]bool{}
|
|
for _, drama := range mergeSourceDramas(cache.Dramas, items, nil, sourceHongguo) {
|
|
ids[drama.ID] = true
|
|
if drama.OnlineDate != "2026-03-16" || drama.Heat != "20" || drama.Views != "0" {
|
|
t.Error("recovery lost primary sorting fields")
|
|
}
|
|
}
|
|
for _, id := range append([]string{"700001", "700002", "700004"}, test.ids...) {
|
|
if !ids["hongguo:"+id] {
|
|
t.Errorf("recovery discarded a valid item from an earlier attempt: %s", id)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHongguoCatalogStopsPersistentStallWithoutLosingCheckpoint(t *testing.T) {
|
|
for _, session := range []string{"expired-session", ""} {
|
|
t.Run(map[bool]string{false: "with session", true: "without session"}[session == ""], func(t *testing.T) {
|
|
calls := map[string]int{}
|
|
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
|
|
payload := hongguoCatalogRequestForTest(t, request)
|
|
calls[payload.Scene]++
|
|
if payload.Scene == "ai_series" {
|
|
if payload.Offset != 18 || calls[payload.Scene] > 2 {
|
|
t.Errorf("stall skipped pages or retried without a bound: %+v", payload)
|
|
}
|
|
return hongguoCatalogResponseForTest(request, 18, true, "bad-session", "700003"), nil
|
|
}
|
|
return hongguoCatalogResponseForTest(request, 18, false, "", "700004"), nil
|
|
})
|
|
d.cfg.MaxPagesPerSort = 5
|
|
saved := hongguoCatalogCursor{Offset: 18, SessionID: session, LastID: "hongguo:700002", Initialized: true, UpdatedAt: time.Now()}
|
|
d.hongguoClient().state.Feeds["ai_series"] = saved
|
|
items, err := d.fetchHongguoAppCatalog(context.WithValue(context.Background(), libraryMoreKey{}, true))
|
|
if err == nil || !strings.Contains(err.Error(), "AI剧: App 分页未前进") || calls["ai_series"] != 2 || len(items) != 2 {
|
|
t.Fatalf("persistent failure did not preserve partial results: calls=%v items=%d error=%v", calls, len(items), err)
|
|
}
|
|
state := d.hongguoCatalogSnapshot()
|
|
if state.Feeds["ai_series"] != saved || !hongguoCatalogHasMore(state) {
|
|
t.Fatalf("persistent failure changed the saved position: %+v", state.Feeds["ai_series"])
|
|
}
|
|
for _, genre := range hongguoAppGenres[:2] {
|
|
if !state.Feeds[genre.key].Exhausted {
|
|
t.Errorf("one stalled feed prevented %s from finishing", genre.name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHongguoCatalogDoesNotRetryAfterCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
calls := 0
|
|
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
|
|
calls++
|
|
cancel()
|
|
return nil, context.Canceled
|
|
})
|
|
d.hongguoClient().state.Feeds[hongguoAppGenres[0].key] = hongguoCatalogCursor{SessionID: "fixture", UpdatedAt: time.Now()}
|
|
_, err := d.fetchHongguoAppCatalog(ctx)
|
|
if !errors.Is(err, context.Canceled) || calls != 1 {
|
|
t.Fatalf("cancelled catalog was retried: calls=%d error=%v", calls, err)
|
|
}
|
|
}
|