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" ) // 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 } // 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() }