package latest import ( "context" "errors" "testing" "time" "bookmarkmanager/backend/internal/store" ) // The series page carries both facts, which is the whole argument for taking // them from one fetch. const asuraSeriesAndCoverFixture = asuraSeriesFixture + asuraCoverFixture const ( acquireKey = "asura:chronicles-of-the-demon-faction-f886a8af" acquireSeriesID = "chronicles-of-the-demon-faction-f886a8af" acquireSeriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af" acquireCoverURL = "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp" ) const ( kaganeKey = "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b" kaganeSeriesID = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b" kaganeSeriesURL = "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b" kaganeImageID = "019fe11a-84c3-7fc3-a84b-88787374b617" kaganeCoverSrc = "https://kagane.to/api/v2/image/" + kaganeImageID + "/compressed" ) // kagane's browser-fetched body is one JSON object carrying both the chapter // list (series_books) and the cover image ids (series_covers), so the single // acquisition fetch yields both facts. const kaganeSeriesAndCoverFixture = `{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",` + `"series_books":[{"book_id":"b","title":"Episode 41","chapter_no":"41","sort_no":41}],` + `"series_covers":[{"cover_id":"019fe11a-84d1-714b-9cf4-2827f277f3c0","language":"en",` + `"image_id":"019fe11a-84c3-7fc3-a84b-88787374b617"}]}` const ( novelfullKey = "novelfull:reverend-insanity" novelfullSeriesID = "reverend-insanity" novelfullSeriesURI = "https://novelfull.com/reverend-insanity.html" novelfullCoverURL = "https://novelfull.com/uploads/webp/novel/reverend-insanity-82661d911a.webp" ) // newAcquirer wires an acquirer onto the store's creation hook, which is how // main wires it: the write path is what starts an acquisition. func newAcquirer(s *store.Store, page *fakeFetcher, covers *fakeBytesCoverFetcher) *Acquirer { a := &Acquirer{Store: s, Fetch: page, Covers: covers} s.OnSeriesCreated = a.Acquire return a } func bookmarkNewSeries(t *testing.T, s *store.Store, seriesURL string) store.Bookmark { t.Helper() stored, err := s.Upsert(s.OwnerID(), store.Bookmark{ Key: acquireKey, Site: "asura", SeriesID: acquireSeriesID, Title: "Chronicles of the Demon Faction", SeriesURL: seriesURL, Cover: "https://evil.example/client-supplied.jpg", UpdatedAt: 1000, }) if err != nil { t.Fatalf("Upsert: %v", err) } return stored } func readBookmark(t *testing.T, s *store.Store, key string) store.Bookmark { t.Helper() b, ok, err := s.Get(s.OwnerID(), key) if err != nil || !ok { t.Fatalf("Get %q = %v, %v", key, ok, err) } return b } func bookmarkNewKaganeSeries(t *testing.T, s *store.Store) store.Bookmark { t.Helper() stored, err := s.Upsert(s.OwnerID(), store.Bookmark{ Key: kaganeKey, Site: "kagane", SeriesID: kaganeSeriesID, Title: "Infinite Decryption", SeriesURL: kaganeSeriesURL, UpdatedAt: 1000, }) if err != nil { t.Fatalf("Upsert: %v", err) } return stored } func bookmarkNewNovelfullSeries(t *testing.T, s *store.Store) store.Bookmark { t.Helper() stored, err := s.Upsert(s.OwnerID(), store.Bookmark{ Key: novelfullKey, Site: "novelfull", SeriesID: novelfullSeriesID, Title: "Reverend Insanity", SeriesURL: novelfullSeriesURI, UpdatedAt: 1000, }) if err != nil { t.Fatalf("Upsert: %v", err) } return stored } // The reported bug: a Reader bookmarks a Series nobody holds and expects the // Cover, not a broken image. Both facts come from the one series-page fetch. func TestAcquireFillsChapterAndCoverFromOneFetch(t *testing.T) { s, _ := newTestStore(t) page := &fakeFetcher{body: asuraSeriesAndCoverFixture, status: 200} covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"} acq := newAcquirer(s, page, covers) // The write itself must not carry the acquisition: it returns before the // Cover exists, and the field is empty until the bytes land. stored := bookmarkNewSeries(t, s, acquireSeriesURL) if stored.Cover != "" { t.Fatalf("Cover on the creating write = %q, want empty", stored.Cover) } acq.Wait() if got := page.callCount(); got != 1 { t.Fatalf("series page fetches = %d, want exactly 1", got) } if got := covers.callCount(); got != 1 { t.Fatalf("cover fetches = %d, want 1", got) } got := readBookmark(t, s, acquireKey) if got.LatestChapterNum == nil || *got.LatestChapterNum != 181 { t.Fatalf("LatestChapterNum = %v, want 181", got.LatestChapterNum) } if want := testCoverBaseURL + "/covers/" + store.CoverAddress(acquireCoverURL); got.Cover != want { t.Fatalf("Cover = %q, want the absolute address %q", got.Cover, want) } body, contentType, ok, err := s.CoverByAddress(store.CoverAddress(acquireCoverURL)) if err != nil || !ok { t.Fatalf("CoverByAddress = %v, %v", ok, err) } if string(body) != "cover-bytes" || contentType != "image/jpeg" { t.Fatalf("stored cover = (%q, %q), want the fetched bytes", body, contentType) } } // A Series that already exists is not re-acquired: no fetch, and the Cover it // already has is left alone. func TestAcquireSkipsAnExistingSeries(t *testing.T) { s, _ := newTestStore(t) page := &fakeFetcher{body: asuraSeriesAndCoverFixture, status: 200} covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"} acq := newAcquirer(s, page, covers) bookmarkNewSeries(t, s, acquireSeriesURL) acq.Wait() bookmarkNewSeries(t, s, acquireSeriesURL) acq.Wait() if got := page.callCount(); got != 1 { t.Fatalf("series page fetches = %d, want 1 — an existing series is not re-acquired", got) } if got := covers.callCount(); got != 1 { t.Fatalf("cover fetches = %d, want 1", got) } got := readBookmark(t, s, acquireKey) if want := testCoverBaseURL + "/covers/" + store.CoverAddress(acquireCoverURL); got.Cover != want { t.Fatalf("Cover = %q, want the acquired one %q", got.Cover, want) } } // A Site that is down costs the Cover and nothing else. func TestAcquireFailureLeavesTheBookmarkIntact(t *testing.T) { cases := []struct { name string page *fakeFetcher covers *fakeBytesCoverFetcher // wantLatest is the chapter that still lands; 0 means none did. wantLatest float64 }{ { "the series page is unreachable", &fakeFetcher{err: errors.New("connection reset")}, &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}, 0, }, { "the series page answers with a challenge", &fakeFetcher{body: challengeFixture, status: 200}, &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}, 0, }, { "only the cover bytes fail", &fakeFetcher{body: asuraSeriesAndCoverFixture, status: 200}, &fakeBytesCoverFetcher{err: errors.New("403")}, 181, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s, _ := newTestStore(t) acq := newAcquirer(s, tc.page, tc.covers) stored := bookmarkNewSeries(t, s, acquireSeriesURL) acq.Wait() got := readBookmark(t, s, acquireKey) if got.Cover != "" { t.Fatalf("Cover = %q, want empty rather than an address that 404s", got.Cover) } if got.Title != stored.Title || got.UpdatedAt != stored.UpdatedAt { t.Fatalf("bookmark = %+v, want it untouched by the failed acquisition", got) } if tc.wantLatest == 0 { if got.LatestChapterNum != nil { t.Fatalf("LatestChapterNum = %v, want none captured", *got.LatestChapterNum) } return } if got.LatestChapterNum == nil || *got.LatestChapterNum != tc.wantLatest { t.Fatalf("LatestChapterNum = %v, want %v", got.LatestChapterNum, tc.wantLatest) } }) } } // series_url arrives in a client-supplied body, so the acquisition reuses the // poller's gate rather than deriving a second one: a non-https scheme, a // site the parsers do not know, or a host pinned to another site is refused // before the server spends a request from its own network position. func TestAcquireRefusesAnUnfetchableSeriesURL(t *testing.T) { for _, seriesURL := range []string{ "http://asurascans.com/comics/x", "file:///etc/passwd", "", } { t.Run(seriesURL, func(t *testing.T) { s, _ := newTestStore(t) page := &fakeFetcher{body: asuraSeriesAndCoverFixture, status: 200} acq := newAcquirer(s, page, &fakeBytesCoverFetcher{}) bookmarkNewSeries(t, s, seriesURL) acq.Wait() if got := page.callCount(); got != 0 { t.Fatalf("fetches for %q = %d, want 0", seriesURL, got) } }) } } // blockingFetcher stands in for a Site that never answers, so a synchronous // acquisition would be visible as a stalled write rather than a slow one. type blockingFetcher struct { release <-chan struct{} body string } func (f *blockingFetcher) Get(ctx context.Context, _ string) (string, int, error) { select { case <-f.release: return f.body, 200, nil case <-ctx.Done(): return "", 0, ctx.Err() } } // The Reader's write may not wait on a third-party Site: with the acquisition // wedged on an unanswering page, the PUT still returns. func TestAcquireDoesNotBlockTheWrite(t *testing.T) { s, _ := newTestStore(t) release := make(chan struct{}) acq := &Acquirer{Store: s, Fetch: &blockingFetcher{release: release, body: asuraSeriesAndCoverFixture}} s.OnSeriesCreated = acq.Acquire upserted := make(chan error, 1) go func() { _, err := s.Upsert(s.OwnerID(), store.Bookmark{ Key: acquireKey, Site: "asura", SeriesID: acquireSeriesID, Title: "Chronicles of the Demon Faction", SeriesURL: acquireSeriesURL, UpdatedAt: 1000, }) upserted <- err }() select { case err := <-upserted: if err != nil { t.Fatalf("Upsert: %v", err) } case <-time.After(10 * time.Second): t.Fatal("the creating write blocked on the acquisition") } close(release) acq.Wait() } // The second symptom of #47: a kagane Series bookmarked from a chapter page // gets its Cover at creation, with the bytes fetched through the browser // sidecar — the only path that clears the challenge — into the // content-addressed store. func TestAcquireKaganeCoverThroughBrowser(t *testing.T) { s, _ := newTestStore(t) tlsPage := &fakeFetcher{body: "", status: 403} browserPage := &fakeFetcher{body: kaganeSeriesAndCoverFixture, status: 200} covers := &fakeCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"} acq := &Acquirer{ Store: s, Fetch: tlsPage, BrowserFetch: browserPage, BrowserCoverFetch: covers, Covers: &fakeBytesCoverFetcher{}, } s.OnSeriesCreated = acq.Acquire bookmarkNewKaganeSeries(t, s) acq.Wait() if got := tlsPage.callCount(); got != 0 { t.Fatalf("plain-TLS page fetches = %d, want 0 — kagane pages are browser-only", got) } if got := browserPage.callCount(); got != 1 { t.Fatalf("browser page fetches = %d, want 1", got) } if got := covers.callCount(); got != 1 { t.Fatalf("browser cover fetches = %d, want 1", got) } if got := covers.calls[0]; got != kaganeCoverSrc { t.Fatalf("browser cover fetched URL %q, want %q", got, kaganeCoverSrc) } got := readBookmark(t, s, kaganeKey) if want := testCoverBaseURL + "/covers/" + store.CoverAddress(kaganeCoverSrc); got.Cover != want { t.Fatalf("Cover = %q, want the content-addressed URL %q", got.Cover, want) } body, contentType, ok, err := s.CoverByAddress(store.CoverAddress(kaganeCoverSrc)) if err != nil || !ok { t.Fatalf("CoverByAddress = %v, %v", ok, err) } if string(body) != "cover-bytes" || contentType != "image/webp" { t.Fatalf("stored cover = (%q, %q), want the browser-fetched bytes", body, contentType) } } // novelfull needs the browser only for its HTML: the cover URL comes out of // the browser-fetched page, but the bytes go over plain TLS through the // ordinary gated fetcher, never through the browser (issue #62). func TestAcquireNovelfullCoverOverPlainTLS(t *testing.T) { s, _ := newTestStore(t) browserPage := &fakeFetcher{body: novelfullSeriesFixture + novelfullCoverFixture, status: 200} covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"} acq := &Acquirer{ Store: s, Fetch: &fakeFetcher{body: "", status: 403}, BrowserFetch: browserPage, Covers: covers, } s.OnSeriesCreated = acq.Acquire bookmarkNewNovelfullSeries(t, s) acq.Wait() if got := browserPage.callCount(); got != 1 { t.Fatalf("browser page fetches = %d, want 1", got) } if got := covers.callCount(); got != 1 { t.Fatalf("cover fetches = %d, want 1 — novelfull bytes never touch the browser", got) } if got := covers.calls[0]; got != novelfullCoverURL { t.Fatalf("cover fetched from %q, want %q", got, novelfullCoverURL) } got := readBookmark(t, s, novelfullKey) if want := testCoverBaseURL + "/covers/" + store.CoverAddress(novelfullCoverURL); got.Cover != want { t.Fatalf("Cover = %q, want %q", got.Cover, want) } } // With no browser sidecar configured, kagane is simply not acquired: no // request is spent on a page that could only ever answer with a challenge, // and nothing falls back to a plain fetch. func TestAcquireKaganeSkippedWithoutBrowser(t *testing.T) { s, _ := newTestStore(t) tlsPage := &fakeFetcher{body: kaganeSeriesAndCoverFixture, status: 200} acq := &Acquirer{ Store: s, Fetch: tlsPage, Covers: &fakeBytesCoverFetcher{body: []byte("x"), contentType: "image/webp"}, } s.OnSeriesCreated = acq.Acquire bookmarkNewKaganeSeries(t, s) acq.Wait() if got := tlsPage.callCount(); got != 0 { t.Fatalf("plain-TLS fetches for kagane = %d, want 0", got) } if got := readBookmark(t, s, kaganeKey); got.Cover != "" { t.Fatalf("Cover = %q, want empty without a browser", got.Cover) } } // The byte half of "nothing falls back to a plain fetch": with a browser for // the page but none for the bytes, a kagane Cover stays absent and the TLS // cover fetcher is never consulted. func TestAcquireKaganeBytesNeverFallBackToPlainTLS(t *testing.T) { s, _ := newTestStore(t) browserPage := &fakeFetcher{body: kaganeSeriesAndCoverFixture, status: 200} tlsCovers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"} acq := &Acquirer{ Store: s, Fetch: &fakeFetcher{body: "", status: 403}, BrowserFetch: browserPage, Covers: tlsCovers, } s.OnSeriesCreated = acq.Acquire bookmarkNewKaganeSeries(t, s) acq.Wait() if got := tlsCovers.callCount(); got != 0 { t.Fatalf("plain-TLS cover fetches = %d, want 0 — kagane bytes are browser-only", got) } if got := readBookmark(t, s, kaganeKey); got.Cover != "" { t.Fatalf("Cover = %q, want empty without a browser cover fetcher", got.Cover) } } // novelfull's no-browser degradation differs from kagane's: only its HTML // needs the sidecar, so when the page body is available — the challenge is a // live time-varying fact that sometimes answers a plain request — the Cover // still lands, bytes over plain TLS. func TestAcquireNovelfullCoverWithoutBrowser(t *testing.T) { s, _ := newTestStore(t) tlsPage := &fakeFetcher{body: novelfullSeriesFixture + novelfullCoverFixture, status: 200} covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"} acq := &Acquirer{Store: s, Fetch: tlsPage, Covers: covers} s.OnSeriesCreated = acq.Acquire bookmarkNewNovelfullSeries(t, s) acq.Wait() if got := covers.callCount(); got != 1 { t.Fatalf("cover fetches = %d, want 1", got) } got := readBookmark(t, s, novelfullKey) if want := testCoverBaseURL + "/covers/" + store.CoverAddress(novelfullCoverURL); got.Cover != want { t.Fatalf("Cover = %q, want %q", got.Cover, want) } }