Files
djk/internal/app/ui_cover_repair_test.go
T

206 lines
7.9 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func coverRepairFixture() Drama {
return Drama{ID: "hongguo:7000000000000000001", Source: sourceHongguo, SourceID: "7000000000000000001", Title: "无图合成剧", Heat: "200", Views: "0", OnlineDate: "2026-09-01",
SortMetadata: &sortMetadataState{Version: sortMetadataVersion, CoverChecked: true, CheckedAt: time.Now().Add(-time.Hour)}}
}
func coverRepairRequest(app *UIApp, id, cover string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{"dramaId": id, "cover": cover})
request := httptest.NewRequest(http.MethodPost, "/api/ui/cover/repair", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/json")
writer := httptest.NewRecorder()
app.routes().ServeHTTP(writer, request)
return writer
}
func TestCoverClickRepairIsolatedCoalescedAndPersisted(t *testing.T) {
for _, broken := range []bool{false, true} {
t.Run(fmt.Sprintf("broken=%t", broken), func(t *testing.T) {
var calls atomic.Int32
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
t.Cleanup(unblock)
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
if request.URL.Path != "/novel/player/video_detail/v1/" || request.Context().Value(backgroundCatalogKey{}) != true {
t.Error("cover repair must only request text metadata at background priority", request.URL.Path)
}
if calls.Add(1) == 1 {
close(entered)
}
<-release
return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":"7000000000000000001","series_cover":%q,"hot_score":9999,"series_title":"不得覆盖标题"}}}`, coverAddressFixture)), nil
})
old := coverRepairFixture()
if broken {
old.Cover, old.CoverURL = coverAddressFixture+"&expired=1", coverAddressFixture+"&expired=1"
normalizeDramaCover(&old)
}
untouched := coverRepairFixture()
untouched.ID = "hongguo:7000000000000000002"
untouched.SourceID = "7000000000000000002"
app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old, untouched}, libraryRevision: 10}
observed, _ := old.Cover.(string)
writers := make(chan *httptest.ResponseRecorder, 12)
go func() { writers <- coverRepairRequest(app, old.ID, observed) }()
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("repair did not start")
}
read := make(chan *httptest.ResponseRecorder, 1)
go func() {
writer := httptest.NewRecorder()
app.handleDramas(writer, httptest.NewRequest(http.MethodGet, "/api/ui/dramas", nil))
read <- writer
}()
select {
case writer := <-read:
if writer.Code != http.StatusOK {
t.Fatal(writer.Body.String())
}
case <-time.After(time.Second):
t.Fatal("cover lookup blocked the library")
}
for index := 1; index < 12; index++ {
go func() { writers <- coverRepairRequest(app, old.ID, observed) }()
}
unblock()
for index := 0; index < 12; index++ {
writer := <-writers
var result coverRepairResult
if writer.Code != http.StatusOK || json.Unmarshal(writer.Body.Bytes(), &result) != nil || coverPathFromAny(result.Cover) != coverAddressFixture {
t.Fatal("click did not receive the repaired address", writer.Code, writer.Body.String())
}
}
if calls.Load() != 1 {
t.Fatal("repeated clicks duplicated the lookup", calls.Load())
}
cache, err := readLibraryCache(d.cfg.dataDirectory())
if err != nil || len(cache.Dramas) != 2 {
t.Fatal("repaired cover was not persisted", err)
}
updated := cache.Dramas[0]
savedOther, _ := json.Marshal(cache.Dramas[1])
originalOther, _ := json.Marshal(untouched)
if bestDramaCover(updated) != coverAddressFixture || updated.Title != old.Title || updated.Heat != old.Heat || updated.Views != old.Views || updated.SortMetadata.Version != old.SortMetadata.Version || string(savedOther) != string(originalOther) {
t.Fatal("repair lost the cover or modified unrelated metadata", updated)
}
app.mu.Lock()
if app.libraryRevision != 11 || app.metadataDone != nil {
t.Error("single cover click migrated historical metadata or missed the revision")
}
app.mu.Unlock()
})
}
}
func TestCoverClickRepairMissingInvalidAndRetry(t *testing.T) {
for _, result := range []struct{ name, id, address string }{
{"missing", "7000000000000000001", ""},
{"wrong drama", "7000000000000000002", coverAddressFixture},
{"invalid address", "7000000000000000001", "https://example.invalid/not-a-cover"},
} {
t.Run(result.name, func(t *testing.T) {
var calls atomic.Int32
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
calls.Add(1)
return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":%q,"series_cover":%q}}}`, result.id, result.address)), nil
})
old := coverRepairFixture()
app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}}
for index := 0; index < 2; index++ {
coverRepairRequest(app, old.ID, "")
}
if calls.Load() != 1 || !reflect.DeepEqual(app.dramas[0], old) {
t.Fatal("failed repair changed metadata or bypassed the cooldown")
}
app.mu.Lock()
app.coverRepairs[old.ID].retryAt = time.Now().Add(-time.Second)
app.mu.Unlock()
coverRepairRequest(app, old.ID, "")
if calls.Load() != 2 {
t.Fatal("a later click could not retry")
}
})
}
}
func TestCoverClickRepairDoesNotOverwriteNewerCover(t *testing.T) {
entered, release := make(chan struct{}), make(chan struct{})
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
close(entered)
<-release
return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":"7000000000000000001","series_cover":%q}}}`, coverAddressFixture)), nil
})
old := coverRepairFixture()
app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}}
done := make(chan *httptest.ResponseRecorder, 1)
go func() { done <- coverRepairRequest(app, old.ID, "") }()
<-entered
newer := coverAddressFixture + "&newer=1"
app.mu.Lock()
app.dramas[0].CoverURL = newer
normalizeDramaCover(&app.dramas[0])
app.mu.Unlock()
close(release)
writer := <-done
var result coverRepairResult
if json.Unmarshal(writer.Body.Bytes(), &result) != nil || coverPathFromAny(result.Cover) != newer || bestDramaCover(app.dramas[0]) != newer {
t.Fatal("late repair overwrote a newer cover", writer.Body.String())
}
app.mu.Lock()
app.dramas[0].CoverURL = newer + "&latest=1"
normalizeDramaCover(&app.dramas[0])
current := app.dramas[0]
app.mu.Unlock()
writer = coverRepairRequest(app, old.ID, current.Cover.(string))
if json.Unmarshal(writer.Body.Bytes(), &result) != nil || coverPathFromAny(result.Cover) != bestDramaCover(current) {
t.Fatal("cached repair returned an obsolete cover", writer.Body.String())
}
}
func TestCoverClickRepairValidationAndCancellation(t *testing.T) {
var calls atomic.Int32
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
calls.Add(1)
<-request.Context().Done()
return nil, request.Context().Err()
})
old := coverRepairFixture()
app := &UIApp{downloader: d, cfg: d.cfg, dramas: []Drama{old}}
for _, input := range []struct{ id, cover string }{
{"outside:1", ""}, {"hongguo:7000000000000000002", ""}, {old.ID, "https://example.invalid/arbitrary"},
} {
coverRepairRequest(app, input.id, input.cover)
}
if calls.Load() != 0 {
t.Fatal("unrecognized IDs or unrelated addresses triggered upstream requests")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := app.repairDramaCover(ctx, old, ""); err == nil {
t.Fatal("canceled lookup succeeded")
}
app.mu.Lock()
defer app.mu.Unlock()
if len(app.coverRepairs) != 0 {
t.Fatal("cancellation left an unretryable pending lookup")
}
}