cbe0a28921
Two owner-gated POST routes write the durable poll_lanes pause stamp the
poller's top-of-pass gate already reads: /admin/lanes/{site}/pause validates
the duration against the fixed 1h/6h/24h allow-list and the Site against the
registry, and /admin/lanes/{site}/resume zeroes the stamp. Both cap the form
body like the API path, answer with the freshly rendered Lanes block, and
never command the poller — the pause is a fact about the Site, so it
survives a restart. The Lanes page's c-ctrl slot now carries the pausebar:
Resume while paused, the duration select plus Pause while running, with the
paused phrase read from the live poll_lanes stamp so a press renders as
paused with no pass having run. Tests cover the round trips, rejections,
body caps, the pause-before-refusal ordering, fresh-poller survival, resume
restoring the full queue, and acquisition being unaffected.
458 lines
16 KiB
Go
458 lines
16 KiB
Go
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 pause governs the Lane only: a Reader's first bookmark of a Series on a
|
|
// paused Site still reads the page, because acquisition is the creation-time
|
|
// fetch, not the poll queue (issue #147).
|
|
func TestAcquireIgnoresLanePause(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
if err := s.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil {
|
|
t.Fatalf("PauseLane: %v", err)
|
|
}
|
|
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()
|
|
|
|
if got := page.callCount(); got != 1 {
|
|
t.Fatalf("series page fetches on a paused Site = %d, want 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|