package app import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "sync" "sync/atomic" "testing" "time" ) func waitSortMetadataQueue(t *testing.T, app *UIApp) { t.Helper() app.mu.Lock() done := app.metadataDone app.mu.Unlock() if done != nil { select { case <-done: case <-time.After(5 * time.Second): t.Fatal("metadata queue did not finish") } } } func TestPrimaryCatalogReturnsBeforeOptionalMetadata(t *testing.T) { for _, source := range []string{sourceHongguo} { t.Run(source, func(t *testing.T) { id := "7615465407347952664" started, release := make(chan struct{}), make(chan struct{}) var once sync.Once var calls atomic.Int32 d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { calls.Add(1) if request.Context().Value(backgroundCatalogKey{}) != true { t.Error("optional detail did not use background priority") } once.Do(func() { close(started) }) 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") != id { t.Error("historical entry was automatically fetched") } return rankingHTTPResponse(request, 200, sortDetailFixture(id, "1773662280")), nil case "/novel/player/video_detail/v1/": return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":%q,"series_title":"详情标题","hot_score":20019,"series_play_cnt":10019}}}`, id)), nil default: t.Errorf("unexpected metadata request: %s", request.URL.Path) return rankingHTTPResponse(request, 404, ""), nil } }) old := Drama{ID: providerDramaID(source, "7498612570866076734"), Source: source, Title: "历史条目"} fresh := Drama{ID: providerDramaID(source, id), Source: source, Title: "列表标题", Heat: "20019", Views: "0", Cover: coverAddressFixture} app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}, librarySources: map[string]librarySourceState{}, libraryLoading: make(chan struct{})} t.Cleanup(app.stopSortMetadata) app.acceptLibraryProgress(source, []Drama{old, fresh}, nil, false) app.mu.Lock() if calls.Load() != 0 || len(app.dramas) != 2 || app.dramas[1].Heat != fresh.Heat || app.dramas[1].Views != "0" { t.Error("primary fields did not return before optional requests") } close(app.libraryLoading) app.libraryLoading = nil app.mu.Unlock() select { case <-started: case <-time.After(2 * time.Second): t.Fatal("new entry was not scheduled automatically") } app.acceptLibraryProgress(source, []Drama{fresh, old}, nil, true) writer := httptest.NewRecorder() app.handleDramas(writer, httptest.NewRequest("GET", "/api/ui/dramas", nil)) var snapshot struct { Loading bool `json:"loading"` Metadata libraryMetadataProgress `json:"metadata"` } if json.Unmarshal(writer.Body.Bytes(), &snapshot) != nil || snapshot.Loading || !snapshot.Metadata.Running || snapshot.Metadata.Total != 1 { t.Fatal("optional metadata blocked the library or duplicated work") } close(release) waitSortMetadataQueue(t, app) cache, err := readLibraryCache(d.cfg.dataDirectory()) if err != nil { t.Fatal(err) } for _, row := range cache.Dramas { if row.ID == old.ID && row.SortMetadata != nil { t.Error("ordinary catalog refresh enrolled historical data") } wantViews := "10019次播放" if source == sourceHongguo { wantViews = "0" } if row.ID == fresh.ID && (row.OnlineDate != "2026-03-16" || row.Views != wantViews || row.Heat != fresh.Heat || row.Title != "列表标题" || row.SortMetadata == nil || row.SortMetadata.Version != sortMetadataVersion || row.SortMetadata.Pending) { t.Errorf("asynchronous metadata was not merged correctly: %+v", row) } } if calls.Load() != 1 { t.Fatalf("duplicate detail requests: %d", calls.Load()) } }) } } func TestResumeOnlyPreviouslyQueuedMetadata(t *testing.T) { var calls atomic.Int32 d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { calls.Add(1) if request.URL.Path != "/detail" || request.URL.Query().Get("series_id") != "7000000000000000002" { t.Errorf("unexpected resource: %s", request.URL.Path) } return rankingHTTPResponse(request, 200, sortDetailFixture("7000000000000000002", "0")), nil }) cache := libraryCache{Dramas: []Drama{ {ID: "hongguo:7000000000000000001", Source: sourceHongguo, Title: "历史条目"}, {ID: "hongguo:7000000000000000002", Source: sourceHongguo, Title: "新条目", Heat: "0", Views: "0", Cover: coverAddressFixture, SortMetadata: &sortMetadataState{Pending: true}}, }} if err := writeLibraryCache(d.cfg.dataDirectory(), cache); err != nil { t.Fatal(err) } app := &UIApp{downloader: d, cfg: d.cfg} t.Cleanup(app.stopSortMetadata) app.loadLibrary() waitSortMetadataQueue(t, app) if calls.Load() != 1 { t.Fatalf("resume requested %d resources, want one pending detail", calls.Load()) } app.mu.Lock() defer app.mu.Unlock() if app.dramas[0].SortMetadata != nil || app.dramas[1].OnlineDate != "" || app.dramas[1].Views != "0" || app.dramas[1].SortMetadata.Version != sortMetadataVersion { t.Fatal("resume changed history, lost zero, or invented an empty source date") } } func TestPrimarySortFieldsNeedNoAdditionalInterfacesWhenComplete(t *testing.T) { hongguo := hongguoDramaFromAny(map[string]any{"series_id": "7615465407347952664", "series_title": "列表剧", "series_cover": coverAddressFixture, "first_visible_time": "1773662280", "hot_score": "20019", "series_play_cnt": "0"}, "测试") d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { t.Errorf("complete primary data triggered an optional request: %s", request.URL.Path) return rankingHTTPResponse(request, 404, ""), nil }) for _, drama := range []Drama{hongguo} { if drama.OnlineDate != "2026-03-16" || drama.Views != "0" || !hasCompleteSortMetadata(drama) { t.Errorf("primary sort fields were lost: %+v", drama) } if _, err := d.fetchDramaSortMetadata(context.Background(), drama); err != nil { t.Fatal(err) } } if hasSortMetric("未知") || hasSortMetric("-1") { t.Fatal("unknown metrics were misclassified as available sorting data") } } func TestBackgroundRequestsYieldToForeground(t *testing.T) { limiter := newRequestLimiter(1, time.Nanosecond) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() request := httptest.NewRequest("GET", "https://hongguoduanju.com/search/test", nil) release, err := limiter.acquire(ctx, request) if err != nil { t.Fatal(err) } background := make(chan func(), 1) foreground := make(chan func(), 1) go func() { unlock, _ := limiter.acquire(context.WithValue(ctx, backgroundCatalogKey{}, true), request) background <- unlock }() go func() { unlock, _ := limiter.acquire(ctx, request) foreground <- unlock }() for { limiter.mu.Lock() waiting := limiter.foregroundWaiting limiter.mu.Unlock() if waiting > 0 { break } if ctx.Err() != nil { t.Fatal("foreground waiter did not register") } time.Sleep(time.Millisecond) } release() select { case unlock := <-foreground: if unlock == nil { t.Fatal("foreground timed out") } select { case <-background: t.Fatal("background ran alongside a waiting foreground request") default: } unlock() case <-background: t.Fatal("optional metadata overtook foreground work") case <-ctx.Done(): t.Fatal(ctx.Err()) } select { case unlock := <-background: if unlock == nil { t.Fatal("background did not resume") } unlock() case <-ctx.Done(): t.Fatal(ctx.Err()) } } func TestOptionalDetailPermissionFailureDoesNotBlockForeground(t *testing.T) { limiter := newRequestLimiter(1, time.Nanosecond) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() foreground := httptest.NewRequest("GET", "https://hongguoduanju.com/search/test", nil).WithContext(ctx) background := foreground.WithContext(context.WithValue(ctx, backgroundCatalogKey{}, true)) limiter.observe(background, &http.Response{StatusCode: http.StatusForbidden, Header: http.Header{}}) if release, err := limiter.acquire(background.Context(), background); err == nil { release() t.Fatal("repeated forbidden details must cool down") } release, err := limiter.acquire(ctx, foreground) if err != nil { t.Fatal("an optional detail's permissions blocked public search", err) } release() limiter.observe(background, &http.Response{StatusCode: http.StatusTooManyRequests, Header: http.Header{"Retry-After": {"60"}}}) if release, err := limiter.acquire(ctx, foreground); err == nil { release() t.Fatal("site rate limits must still be respected") } }