58014eb8dd
Two-axis review found the shipped colour and three weak tests. - --patina was a warm gold at the same hue family as --brass; the spec asked for a cool blue-green so an unhealthy Lane is unmistakably not ember. Now verdigris in both branches, with docs/design-system.md stating why the far side of the wheel is the point. - A Lane pass that returns before computing its figures carries the previous pass's due count and gap forward instead of recording zeroes, and Checked rides beside Due so a stopped Lane is distinguishable from a quiet one. - TestAdminPageWithoutAPollerSaysSo now separates the two causes it conflated, TestOwnerClearsReaderMarks seeds real counters so the clearing assertion can fail, and TestLaneStatus asserts Checked and the carry-forward. - backend/AGENTS.md records the one deliberate owner comparison outside requireOwner and the carry-forward rule.
1671 lines
59 KiB
Go
1671 lines
59 KiB
Go
package latest
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/pgtest"
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
|
|
|
// testOwner is the owner every test store seeds. A second reader, where a
|
|
// test needs one, is created by opening the same database as a second owner.
|
|
var testOwner = store.Owner{DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash"))}
|
|
|
|
// testCoverBaseURL is the public origin every stored cover URL is built from.
|
|
const testCoverBaseURL = "https://bookmarks.test"
|
|
|
|
// newTestStore opens a store on a Postgres database of this test's own and
|
|
// returns the URL, for helpers that need a second connection to the same
|
|
// database (see TestRunOnceFetchesSharedSeriesOnce).
|
|
func newTestStore(t *testing.T) (*store.Store, string) {
|
|
t.Helper()
|
|
url := pgtest.URL(t)
|
|
s, err := store.Open(url, testOwner, t.TempDir(), testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
return s, url
|
|
}
|
|
|
|
// seedForCheck inserts a bookmark (and with it its series) and forces the
|
|
// series' latest_checked_at.
|
|
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
|
|
t.Helper()
|
|
site, seriesID, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
t.Fatalf("key %q: no ':' separator", key)
|
|
}
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key,
|
|
Site: site,
|
|
SeriesID: seriesID,
|
|
SeriesURL: seriesURL,
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed %q: %v", key, err)
|
|
}
|
|
if err := s.MarkLatestChecked(site, seriesID, checkedAt); err != nil {
|
|
t.Fatalf("seed mark %q: %v", key, err)
|
|
}
|
|
}
|
|
|
|
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
|
|
t.Helper()
|
|
site, seriesID, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
t.Fatalf("key %q: no ':' separator", key)
|
|
}
|
|
ts, err := s.LatestCheckedAt(site, seriesID)
|
|
if err != nil {
|
|
t.Fatalf("LatestCheckedAt %q: %v", key, err)
|
|
}
|
|
return ts
|
|
}
|
|
|
|
// fakeFetcher stands in for the network. Every poller test uses it, so nothing
|
|
// in this file can reach tls-client or a real site.
|
|
type fakeFetcher struct {
|
|
mu sync.Mutex
|
|
calls []string
|
|
body string
|
|
status int
|
|
err error
|
|
// perURL overrides body/status/err for specific URLs.
|
|
perURL map[string]fakeResponse
|
|
}
|
|
|
|
type fakeResponse struct {
|
|
body string
|
|
status int
|
|
err error
|
|
}
|
|
|
|
func (f *fakeFetcher) Get(ctx context.Context, url string) (string, int, error) {
|
|
f.mu.Lock()
|
|
f.calls = append(f.calls, url)
|
|
f.mu.Unlock()
|
|
if r, ok := f.perURL[url]; ok {
|
|
return r.body, r.status, r.err
|
|
}
|
|
return f.body, f.status, f.err
|
|
}
|
|
|
|
func (f *fakeFetcher) callCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.calls)
|
|
}
|
|
|
|
type fakeCoverFetcher struct {
|
|
mu sync.Mutex
|
|
calls []string
|
|
body []byte
|
|
contentType string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeCoverFetcher) Image(_ context.Context, imageURL string) ([]byte, string, error) {
|
|
f.mu.Lock()
|
|
f.calls = append(f.calls, imageURL)
|
|
f.mu.Unlock()
|
|
if f.err != nil {
|
|
return nil, "", f.err
|
|
}
|
|
return f.body, f.contentType, nil
|
|
}
|
|
|
|
func (f *fakeCoverFetcher) callCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.calls)
|
|
}
|
|
|
|
type fakeBytesCoverFetcher struct {
|
|
mu sync.Mutex
|
|
calls []string
|
|
body []byte
|
|
contentType string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeBytesCoverFetcher) Fetch(_ context.Context, sourceURL string) ([]byte, string, error) {
|
|
f.mu.Lock()
|
|
f.calls = append(f.calls, sourceURL)
|
|
f.mu.Unlock()
|
|
if f.err != nil {
|
|
return nil, "", f.err
|
|
}
|
|
return f.body, f.contentType, nil
|
|
}
|
|
|
|
func (f *fakeBytesCoverFetcher) callCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.calls)
|
|
}
|
|
|
|
// newTestPoller wires a poller with a frozen clock. Rest and gap come from the
|
|
// Site registry, so tests seed checked_at relative to the one-hour rest; the
|
|
// round entry point (runOnce) runs every Lane back to back with no real
|
|
// pacing, so tests stay instant and deterministic.
|
|
func newTestPoller(t *testing.T, s *store.Store, f Fetcher, at time.Time) *Poller {
|
|
t.Helper()
|
|
return &Poller{
|
|
Store: s,
|
|
Fetch: f,
|
|
Now: func() time.Time { return at },
|
|
}
|
|
}
|
|
|
|
// seedCoverSource writes a Series' cover source address without any stored
|
|
// bytes. Nothing in production produces that state any more — a client cover
|
|
// is discarded and an acquired one arrives with its bytes — but rows created
|
|
// before covers moved server-side still carry one, and the prefetch is what
|
|
// heals them.
|
|
func seedCoverSource(t *testing.T, dbURL, site, seriesID, coverURL string) {
|
|
t.Helper()
|
|
db, err := sql.Open("pgx", dbURL)
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", dbURL, err)
|
|
}
|
|
defer db.Close()
|
|
if _, err := db.Exec(
|
|
`UPDATE series SET cover = $3 WHERE site = $1 AND series_id = $2`,
|
|
site, seriesID, coverURL); err != nil {
|
|
t.Fatalf("seed cover source %s:%s: %v", site, seriesID, err)
|
|
}
|
|
}
|
|
|
|
func TestRunOncePrefetchesPublicCover(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
const (
|
|
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
|
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
|
coverURL = "https://cdn.example/covers/chronicles.jpg"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "asura", SeriesID: "chronicles-of-the-demon-faction-f886a8af",
|
|
SeriesURL: seriesURL, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af", coverURL)
|
|
covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture, status: 200}, CoverBytesFetch: covers,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
if got := covers.callCount(); got != 1 {
|
|
t.Fatalf("cover fetch calls = %d, want 1", got)
|
|
}
|
|
body, contentType, found, err := s.GetCover(coverURL)
|
|
if err != nil || !found {
|
|
t.Fatalf("GetCover: %v found=%v", err, found)
|
|
}
|
|
if string(body) != "cover-bytes" || contentType != "image/jpeg" {
|
|
t.Fatalf("stored cover = (%q, %q), want (cover-bytes, image/jpeg)", body, contentType)
|
|
}
|
|
}
|
|
|
|
func TestRunOnceDoesNotStoreNonImagePublicCover(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
const (
|
|
key = "asura:non-image-cover"
|
|
seriesURL = "https://asurascans.com/comics/non-image-cover"
|
|
coverURL = "https://cdn.example/covers/challenge"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "asura", SeriesID: "non-image-cover", SeriesURL: seriesURL,
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "asura", "non-image-cover", coverURL)
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture, status: 200},
|
|
CoverBytesFetch: &fakeBytesCoverFetcher{body: []byte("challenge"), contentType: "text/html"},
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
if _, _, found, err := s.GetCover(coverURL); err != nil || found {
|
|
t.Fatalf("non-image cover = found %v, err %v; want missing", found, err)
|
|
}
|
|
}
|
|
|
|
func TestRunOnceRecordsLatestChapter(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
const url = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
|
seedForCheck(t, s, "asura:chronicles-of-the-demon-faction-f886a8af", url, 0)
|
|
|
|
now := time.UnixMilli(5_000_000)
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
newTestPoller(t, s, f, now).runOnce(context.Background())
|
|
|
|
b, ok, err := s.Get(s.OwnerID(), "asura:chronicles-of-the-demon-faction-f886a8af")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get: %v ok=%v", err, ok)
|
|
}
|
|
if b.LatestChapterNum == nil || *b.LatestChapterNum != 181 {
|
|
t.Fatalf("LatestChapterNum = %v, want 181", b.LatestChapterNum)
|
|
}
|
|
if b.LatestChapter != "Chapter 181" {
|
|
t.Fatalf("LatestChapter = %q, want %q", b.LatestChapter, "Chapter 181")
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "asura:chronicles-of-the-demon-faction-f886a8af"); got != now.UnixMilli() {
|
|
t.Fatalf("latest_checked_at = %d, want %d", got, now.UnixMilli())
|
|
}
|
|
}
|
|
|
|
// The whole point of the updated_at CASE in Upsert: a newly published chapter is
|
|
// not reading progress and must not move the series up the list.
|
|
func TestRunOnceDoesNotReorderList(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
const url = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
|
const key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
|
|
|
// "other" is the most recently read, so it must stay at the top of List().
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: "asura:other", Site: "asura", SeriesID: "other",
|
|
SeriesURL: "https://asurascans.com/comics/other", UpdatedAt: 9_000_000,
|
|
}); err != nil {
|
|
t.Fatalf("seed other: %v", err)
|
|
}
|
|
seedForCheck(t, s, key, url, 0)
|
|
before, _, err := s.Get(s.OwnerID(), key)
|
|
if err != nil {
|
|
t.Fatalf("Get before: %v", err)
|
|
}
|
|
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
newTestPoller(t, s, f, time.UnixMilli(9_999_999)).runOnce(context.Background())
|
|
|
|
after, _, err := s.Get(s.OwnerID(), key)
|
|
if err != nil {
|
|
t.Fatalf("Get after: %v", err)
|
|
}
|
|
if after.UpdatedAt != before.UpdatedAt {
|
|
t.Fatalf("updated_at moved from %d to %d on a latest-chapter bump",
|
|
before.UpdatedAt, after.UpdatedAt)
|
|
}
|
|
list, err := s.List(s.OwnerID())
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if list[0].Key != "asura:other" {
|
|
t.Fatalf("list reordered: head is %q, want asura:other", list[0].Key)
|
|
}
|
|
}
|
|
|
|
// A failed fetch must still consume the cooldown, or a renamed series gets
|
|
// retried on every tick forever.
|
|
func TestRunOnceMarksCheckedOnFailure(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resp fakeResponse
|
|
}{
|
|
{"network error", fakeResponse{err: errors.New("dial tcp: refused")}},
|
|
{"non-200", fakeResponse{body: "nope", status: 503}},
|
|
{"challenge page", fakeResponse{body: challengeFixture, status: 200}},
|
|
{"empty body", fakeResponse{body: "", status: 200}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
const url = "https://asurascans.com/comics/x"
|
|
seedForCheck(t, s, "asura:x", url, 0)
|
|
|
|
now := time.UnixMilli(7_000_000)
|
|
f := &fakeFetcher{perURL: map[string]fakeResponse{url: tt.resp}}
|
|
newTestPoller(t, s, f, now).runOnce(context.Background())
|
|
|
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != now.UnixMilli() {
|
|
t.Fatalf("latest_checked_at = %d, want %d", got, now.UnixMilli())
|
|
}
|
|
b, _, err := s.Get(s.OwnerID(), "asura:x")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if b.LatestChapterNum != nil {
|
|
t.Fatalf("LatestChapterNum = %v, want nil on a failed check", *b.LatestChapterNum)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The point of the split (ADR-0003): a series referenced by several bookmarks
|
|
// is fetched once per due cycle, not once per bookmark. Two bookmarks share a
|
|
// series when two readers track it (issue #22).
|
|
func TestRunOnceFetchesSharedSeriesOnce(t *testing.T) {
|
|
s, url := newTestStore(t)
|
|
// The slug must match the fixture's own anchors: asura's parser scopes
|
|
// chapter links to the stored slug.
|
|
const slug = "chronicles-of-the-demon-faction-f886a8af"
|
|
const seriesURL = "https://asurascans.com/comics/" + slug
|
|
seedForCheck(t, s, "asura:"+slug, seriesURL, 0)
|
|
// A second reader tracks the same series. The seed is the only
|
|
// reader-creation path, so a second Open as a different owner is how a
|
|
// test gets a second reader on the same database.
|
|
other, err := store.Open(url, store.Owner{DiscordID: "second-reader", TokenHash: sha256.Sum256([]byte("second-token-hash"))}, t.TempDir(), testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open second reader: %v", err)
|
|
}
|
|
t.Cleanup(func() { other.Close() })
|
|
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
|
|
Key: "asura:" + slug, Site: "asura", SeriesID: slug, UpdatedAt: 2000,
|
|
}); err != nil {
|
|
t.Fatalf("seed second reader: %v", err)
|
|
}
|
|
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
newTestPoller(t, s, f, time.UnixMilli(5_000_000)).runOnce(context.Background())
|
|
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("fetched shared series %d times, want 1", got)
|
|
}
|
|
// Both bookmarks join to the same updated series row.
|
|
for _, st := range []*store.Store{s, other} {
|
|
b, ok, err := st.Get(st.OwnerID(), "asura:"+slug)
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get: %v ok=%v", err, ok)
|
|
}
|
|
if b.LatestChapterNum == nil || *b.LatestChapterNum != 181 {
|
|
t.Fatalf("LatestChapterNum = %v, want 181", b.LatestChapterNum)
|
|
}
|
|
}
|
|
}
|
|
|
|
// One unreachable series must not abandon the rest of the Lane.
|
|
func TestRunOnceOneBadSeriesDoesNotStallLane(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
keys := []string{"asura:a", "asura:b", "asura:c", "asura:d", "asura:e"}
|
|
for _, k := range keys {
|
|
seedForCheck(t, s, k, "https://asurascans.com/comics/"+k, 0)
|
|
}
|
|
|
|
now := time.UnixMilli(6_000_000)
|
|
f := &fakeFetcher{
|
|
body: "", status: 200,
|
|
perURL: map[string]fakeResponse{
|
|
"https://asurascans.com/comics/asura:b": {err: errors.New("boom")},
|
|
},
|
|
}
|
|
newTestPoller(t, s, f, now).runOnce(context.Background())
|
|
|
|
if got := f.callCount(); got != 5 {
|
|
t.Fatalf("fetched %d series, want all 5 attempted", got)
|
|
}
|
|
for _, k := range keys {
|
|
if got := readLatestCheckedAt(t, s, k); got != now.UnixMilli() {
|
|
t.Fatalf("%s latest_checked_at = %d, want %d", k, got, now.UnixMilli())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pace now lives in the registry, not config: Run logs the lane defaults so a
|
|
// deployment can see what the poller is doing without reading the source.
|
|
func TestRunLogsLaneDefaults(t *testing.T) {
|
|
var logs strings.Builder
|
|
previous := log.Writer()
|
|
log.SetOutput(&logs)
|
|
t.Cleanup(func() { log.SetOutput(previous) })
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
(&Poller{Now: func() time.Time { return time.Now() }}).Run(ctx)
|
|
|
|
got := logs.String()
|
|
for _, want := range []string{"6 lanes", "rest=1h0m0s", "gap=10s"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("startup log = %q, want %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The rest is enforced by the due query, so a second immediate pass must do
|
|
// nothing at all — this is what makes the Lane's sleep independent of it.
|
|
func TestRunOnceHonoursRestAcrossPasses(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
const url = "https://asurascans.com/comics/x"
|
|
seedForCheck(t, s, "asura:x", url, 0)
|
|
|
|
now := time.UnixMilli(8_000_000)
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
p := newTestPoller(t, s, f, now)
|
|
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("first pass fetched %d, want 1", got)
|
|
}
|
|
// Same instant, and again 59 minutes later: both inside the 1h rest.
|
|
p.runOnce(context.Background())
|
|
p.Now = func() time.Time { return now.Add(59 * time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("fetched %d times inside the rest, want 1", got)
|
|
}
|
|
// Past the rest, it is due again.
|
|
p.Now = func() time.Time { return now.Add(61 * time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 2 {
|
|
t.Fatalf("fetched %d times after the rest, want 2", got)
|
|
}
|
|
}
|
|
|
|
// A site that retracts a chapter should correct the stored number downward,
|
|
// mirroring the userscript's equality check (L427) rather than a >.
|
|
func TestRunOnceCorrectsDownward(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
const url = "https://demonicscans.org/manga/Catastrophic-Necromancer"
|
|
const key = "demonic:Catastrophic-Necromancer"
|
|
|
|
high := 400.0
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "demonic", SeriesID: "Catastrophic-Necromancer",
|
|
SeriesURL: url, LatestChapter: "Chapter 400", LatestChapterNum: &high,
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
f := &fakeFetcher{body: demonicSeriesFixture, status: 200}
|
|
newTestPoller(t, s, f, time.UnixMilli(5_000_000)).runOnce(context.Background())
|
|
|
|
b, _, err := s.Get(s.OwnerID(), key)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if b.LatestChapterNum == nil || *b.LatestChapterNum != 296 {
|
|
t.Fatalf("LatestChapterNum = %v, want 296", b.LatestChapterNum)
|
|
}
|
|
}
|
|
|
|
// series_url is client-supplied via PUT /bookmarks/{key}, so checkOne must
|
|
// reject anything that is not a known site with an https URL before spending a
|
|
// request on it — the cooldown still gets consumed either way.
|
|
func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
site string
|
|
seriesURL string
|
|
wantCalls int
|
|
}{
|
|
{"unknown site", "mangadex", "https://mangadex.org/title/x", 0},
|
|
{"http scheme", "asura", "http://asurascans.com/comics/x", 0},
|
|
{"unparseable url", "asura", "http://[::1", 0},
|
|
{"valid https asura", "asura", "https://asurascans.com/comics/x", 1},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
key := tt.site + ":x"
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL,
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
now := time.UnixMilli(4_000_000)
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
newTestPoller(t, s, f, now).checkOne(context.Background(), store.Series{
|
|
Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL,
|
|
})
|
|
|
|
if got := f.callCount(); got != tt.wantCalls {
|
|
t.Fatalf("fetch calls = %d, want %d", got, tt.wantCalls)
|
|
}
|
|
if got := readLatestCheckedAt(t, s, key); got != now.UnixMilli() {
|
|
t.Fatalf("latest_checked_at = %d, want %d (cooldown must be consumed regardless)", got, now.UnixMilli())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A cancelled context must abandon the batch rather than run it to completion.
|
|
func TestRunOnceStopsOnCancelledContext(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
for _, k := range []string{"asura:a", "asura:b", "asura:c"} {
|
|
seedForCheck(t, s, k, "https://asurascans.com/comics/"+k, 0)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
f := &fakeFetcher{body: "", status: 200}
|
|
newTestPoller(t, s, f, time.UnixMilli(5_000_000)).runOnce(ctx)
|
|
|
|
if got := f.callCount(); got != 0 {
|
|
t.Fatalf("fetched %d series with a cancelled context, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchableSeriesURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
site string
|
|
seriesURL string
|
|
want bool
|
|
}{
|
|
{"asura https", "asura", "https://asurascans.com/comics/x-aabbccdd", true},
|
|
{"demonic https", "demonic", "https://demonicscans.org/manga/X", true},
|
|
{"comix https", "comix", "https://comix.to/title/n8we-dungeons-and-crayons", true},
|
|
{"kagane on its own host", "kagane", "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b", true},
|
|
// The three plain-TLS Sites are pinned too: a client-supplied
|
|
// series_url must not aim a fetcher at a lookalike host, even when
|
|
// the fetcher is only an HTTP GET.
|
|
{"asura on a foreign host", "asura", "https://asurascans.com.evil.example/comics/x", false},
|
|
{"asura on the dead old domain", "asura", "https://asuracomic.net/comics/x", false},
|
|
{"demonic on a lookalike host", "demonic", "https://demonicscans.org.evil.example/manga/X", false},
|
|
{"comix on a foreign host", "comix", "https://evil.example/title/x", false},
|
|
// The browser fetcher runs JavaScript and carries cookies, so a
|
|
// client-supplied series_url must not be able to aim it anywhere else.
|
|
{"kagane on a foreign host", "kagane", "https://evil.example/series/x", false},
|
|
{"kagane on a lookalike host", "kagane", "https://kagane.to.evil.example/series/x", false},
|
|
{"unknown site", "mangadex", "https://mangadex.org/title/x", false},
|
|
{"non-https", "comix", "http://comix.to/title/x", false},
|
|
{"no host", "comix", "https:///title/x", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := fetchableSeriesURL(tt.site, tt.seriesURL); got != tt.want {
|
|
t.Errorf("fetchableSeriesURL(%q, %q) = %v, want %v",
|
|
tt.site, tt.seriesURL, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A kagane row must not be handed to the plain TLS fetcher: it would only ever
|
|
// receive a challenge page, and the browser fetcher is the whole reason kagane
|
|
// is pollable at all.
|
|
func TestKaganeSkippedWhenNoBrowserFetcher(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
|
Site: "kagane",
|
|
SeriesID: "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
|
SeriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
f := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
|
|
|
p := &Poller{
|
|
Store: s,
|
|
Fetch: f,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
|
|
if len(f.calls) != 0 {
|
|
t.Errorf("TLS fetcher was called for kagane: %v", f.calls)
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"); got != 0 {
|
|
t.Errorf("latest_checked_at = %d, want 0 (untried stays due until a browser appears)", got)
|
|
}
|
|
}
|
|
|
|
// novelfull without a browser is not skipped outright: its challenge is a
|
|
// live time-varying fact, so the plain-TLS page fetch is attempted and — when
|
|
// the body answers — fills both the chapter and the Cover, exactly the
|
|
// client-scraped rows #62 wants healed.
|
|
func TestNovelfullUsesTLSWhenNoBrowserFetcher(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
key := "novelfull:reverend-insanity"
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key,
|
|
Site: "novelfull",
|
|
SeriesID: "reverend-insanity",
|
|
SeriesURL: "https://novelfull.com/reverend-insanity.html",
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
tlsF := &fakeFetcher{body: novelfullSeriesFixture + novelfullCoverFixture, status: 200}
|
|
covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"}
|
|
p := &Poller{
|
|
Store: s, Fetch: tlsF, CoverBytesFetch: covers,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
if len(tlsF.calls) != 1 {
|
|
t.Fatalf("TLS fetcher calls = %d, want 1", len(tlsF.calls))
|
|
}
|
|
if got := covers.callCount(); got != 1 {
|
|
t.Fatalf("cover fetches = %d, want 1", got)
|
|
}
|
|
got, found, err := s.Get(s.OwnerID(), key)
|
|
if err != nil || !found {
|
|
t.Fatalf("Get: %v found=%v", err, found)
|
|
}
|
|
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(novelfullCoverURL); got.Cover != want {
|
|
t.Fatalf("Cover = %q, want %q", got.Cover, want)
|
|
}
|
|
if got.LatestChapterNum == nil || *got.LatestChapterNum != 2334 {
|
|
t.Fatalf("LatestChapterNum = %v, want 2334", got.LatestChapterNum)
|
|
}
|
|
}
|
|
|
|
// With a browser fetcher wired up, kagane goes to it and not to the TLS one.
|
|
func TestKaganeUsesBrowserFetcher(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
key := "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key,
|
|
Site: "kagane",
|
|
SeriesID: "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
|
SeriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
tlsF := &fakeFetcher{body: "", status: 200}
|
|
browserF := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
|
p := &Poller{
|
|
Store: s, Fetch: tlsF, BrowserFetch: browserF,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
|
|
if len(tlsF.calls) != 0 {
|
|
t.Errorf("TLS fetcher was called for kagane: %v", tlsF.calls)
|
|
}
|
|
if len(browserF.calls) != 1 {
|
|
t.Fatalf("browser fetcher calls = %v, want 1", browserF.calls)
|
|
}
|
|
got, found, err := s.Get(s.OwnerID(), key)
|
|
if err != nil || !found {
|
|
t.Fatalf("Get: %v found=%v", err, found)
|
|
}
|
|
if got.LatestChapterNum == nil || *got.LatestChapterNum != 41 {
|
|
t.Errorf("LatestChapterNum = %v, want 41", got.LatestChapterNum)
|
|
}
|
|
}
|
|
|
|
// comix joined kagane behind the challenge on 2026-08-12 (#98): its page goes
|
|
// to the browser, its Cover bytes go through the browser's image route because
|
|
// static.comix.to is gated the same way, and the TLS fetcher is never asked
|
|
// for either.
|
|
func TestComixUsesBrowserFetcher(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
const (
|
|
key = "comix:n8we-dungeons-and-crayons"
|
|
seriesID = "n8we-dungeons-and-crayons"
|
|
seriesURL = "https://comix.to/title/n8we-dungeons-and-crayons"
|
|
coverURL = "https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "comix", SeriesID: seriesID, SeriesURL: seriesURL,
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "comix", seriesID, coverURL)
|
|
|
|
tlsF := &fakeFetcher{body: "", status: 200}
|
|
browserF := &fakeFetcher{body: comixSeriesFixture, status: 200}
|
|
covers := &fakeCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
|
|
tlsCovers := &fakeBytesCoverFetcher{body: []byte("tls-bytes"), contentType: "image/jpeg"}
|
|
p := &Poller{
|
|
Store: s, Fetch: tlsF, BrowserFetch: browserF,
|
|
CoverFetch: covers, CoverBytesFetch: tlsCovers,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
if len(tlsF.calls) != 0 {
|
|
t.Errorf("TLS fetcher was called for comix: %v", tlsF.calls)
|
|
}
|
|
if len(browserF.calls) != 1 {
|
|
t.Fatalf("browser fetcher calls = %v, want 1", browserF.calls)
|
|
}
|
|
if got := tlsCovers.callCount(); got != 0 {
|
|
t.Errorf("TLS cover fetches = %d, want 0: static.comix.to answers a challenge", got)
|
|
}
|
|
if got := covers.callCount(); got != 1 {
|
|
t.Fatalf("browser cover fetches = %d, want 1", got)
|
|
}
|
|
got, found, err := s.Get(s.OwnerID(), key)
|
|
if err != nil || !found {
|
|
t.Fatalf("Get: %v found=%v", err, found)
|
|
}
|
|
if got.LatestChapterNum == nil || *got.LatestChapterNum != 80 {
|
|
t.Errorf("LatestChapterNum = %v, want 80", got.LatestChapterNum)
|
|
}
|
|
}
|
|
|
|
// Without a browser, comix is skipped outright rather than handed to plain
|
|
// TLS: a plain fetch retrieves only a challenge page (measured 2026-08-12).
|
|
func TestComixSkippedWhenNoBrowserFetcher(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: "comix:n8we-dungeons-and-crayons", Site: "comix",
|
|
SeriesID: "n8we-dungeons-and-crayons",
|
|
SeriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
f := &fakeFetcher{body: comixSeriesFixture, status: 200}
|
|
p := &Poller{
|
|
Store: s, Fetch: f,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
|
|
if len(f.calls) != 0 {
|
|
t.Errorf("TLS fetcher was called for comix: %v", f.calls)
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "comix:n8we-dungeons-and-crayons"); got != 0 {
|
|
t.Errorf("latest_checked_at = %d, want 0 (untried stays due until a browser appears)", got)
|
|
}
|
|
}
|
|
|
|
func TestRunOncePrefetchesKaganeCover(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
const (
|
|
key = "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
coverURL = "https://kagane.to/api/v2/image/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b/compressed"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "kagane", SeriesID: "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
|
SeriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "kagane", "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b", coverURL)
|
|
covers := &fakeCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"}
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
|
|
BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200}, CoverFetch: covers,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
body, contentType, ok, err := s.CoverByAddress(store.CoverAddress(coverURL))
|
|
if err != nil || !ok {
|
|
t.Fatalf("CoverByAddress: %v found=%v", err, ok)
|
|
}
|
|
if string(body) != "cover-bytes" || contentType != "image/webp" {
|
|
t.Fatalf("stored cover = (%q, %q), want (cover-bytes, image/webp)", body, contentType)
|
|
}
|
|
if got := covers.callCount(); got != 1 {
|
|
t.Fatalf("cover fetch calls = %d, want 1", got)
|
|
}
|
|
if got := readBookmark(t, s, key); got.Cover != testCoverBaseURL+"/covers/"+store.CoverAddress(coverURL) {
|
|
t.Fatalf("wire Cover = %q, want content-addressed URL", got.Cover)
|
|
}
|
|
}
|
|
|
|
func TestRunOnceDoesNotRefetchKaganeCover(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
const (
|
|
key = "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
seriesID = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
coverURL = "https://kagane.to/api/v2/image/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b/compressed"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "kagane", SeriesID: seriesID,
|
|
SeriesURL: "https://kagane.to/series/" + seriesID, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "kagane", seriesID, coverURL)
|
|
at := time.UnixMilli(5_000_000)
|
|
covers := &fakeCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"}
|
|
p := &Poller{
|
|
Store: s, BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200}, CoverFetch: covers,
|
|
Now: func() time.Time { return at },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
at = at.Add(2 * time.Hour)
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
if got := covers.callCount(); got != 1 {
|
|
t.Fatalf("cover fetch calls = %d, want 1 after two due cycles", got)
|
|
}
|
|
}
|
|
|
|
func TestRunOnceCoverFailureDoesNotBlockChapter(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
const (
|
|
key = "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
seriesID = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
coverURL = "https://kagane.to/api/v2/image/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b/compressed"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "kagane", SeriesID: seriesID,
|
|
SeriesURL: "https://kagane.to/series/" + seriesID, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "kagane", seriesID, coverURL)
|
|
now := time.UnixMilli(5_000_000)
|
|
p := &Poller{
|
|
Store: s, BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
|
|
CoverFetch: &fakeCoverFetcher{err: errors.New("browser unavailable")},
|
|
Now: func() time.Time { return now },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
got, found, err := s.Get(s.OwnerID(), key)
|
|
if err != nil || !found {
|
|
t.Fatalf("Get: %v found=%v", err, found)
|
|
}
|
|
if got.LatestChapterNum == nil || *got.LatestChapterNum != 41 {
|
|
t.Fatalf("LatestChapterNum = %v, want 41", got.LatestChapterNum)
|
|
}
|
|
if checked := readLatestCheckedAt(t, s, key); checked != now.UnixMilli() {
|
|
t.Fatalf("latest_checked_at = %d, want %d", checked, now.UnixMilli())
|
|
}
|
|
}
|
|
|
|
func TestRunOnceRejectsInvalidKaganeCover(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
const (
|
|
key = "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
seriesID = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
coverURL = "https://kagane.to/api/v2/image/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b/compressed"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "kagane", SeriesID: seriesID,
|
|
SeriesURL: "https://kagane.to/series/" + seriesID, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "kagane", seriesID, coverURL)
|
|
p := &Poller{
|
|
Store: s, BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
|
|
CoverFetch: &fakeCoverFetcher{body: []byte("not an image"), contentType: "text/html"},
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
if _, _, found, err := s.CoverByAddress(store.CoverAddress(coverURL)); err != nil || found {
|
|
t.Fatalf("invalid cover persisted = %v, err %v; want missing", found, err)
|
|
}
|
|
}
|
|
|
|
func TestRunOnceWithoutCoverFetcherStillPollsKagane(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
const (
|
|
key = "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
seriesID = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
|
coverURL = "https://kagane.to/api/v2/image/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b/compressed"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "kagane", SeriesID: seriesID,
|
|
SeriesURL: "https://kagane.to/series/" + seriesID, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "kagane", seriesID, coverURL)
|
|
p := &Poller{
|
|
Store: s, BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
if _, _, found, err := s.CoverByAddress(store.CoverAddress(coverURL)); err != nil || found {
|
|
t.Fatalf("cover after nil CoverFetch = found %v, err %v; want missing", found, err)
|
|
}
|
|
}
|
|
|
|
func TestRunOnceRoutesNonKaganeCoverToPublicFetcher(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
const key = "asura:solo"
|
|
const coverURL = "https://asurascans.com/covers/solo.jpg"
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "asura", SeriesID: "solo", SeriesURL: "https://asurascans.com/comics/solo",
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "asura", "solo", coverURL)
|
|
browserCovers := &fakeCoverFetcher{body: []byte("must not be fetched"), contentType: "image/webp"}
|
|
publicCovers := &fakeBytesCoverFetcher{body: []byte("public cover"), contentType: "image/webp"}
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture, status: 200}, CoverFetch: browserCovers,
|
|
CoverBytesFetch: publicCovers,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
if got := publicCovers.callCount(); got != 1 {
|
|
t.Fatalf("public cover fetch calls = %d, want 1", got)
|
|
}
|
|
if got := browserCovers.callCount(); got != 0 {
|
|
t.Fatalf("browser cover fetch calls for asura = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestFetcherForRoutesNovelSites(t *testing.T) {
|
|
tls := &fakeFetcher{}
|
|
browser := &fakeFetcher{}
|
|
|
|
cases := []struct {
|
|
site string
|
|
browser Fetcher
|
|
tls Fetcher
|
|
want Fetcher
|
|
}{
|
|
{"asura", browser, tls, tls},
|
|
{"lightnovelworld", browser, tls, tls},
|
|
{"kagane", browser, tls, browser},
|
|
{"novelfull", browser, tls, browser},
|
|
// browser-less deployment: kagane is nothing, novelfull degrades to TLS
|
|
{"kagane", nil, tls, nil},
|
|
{"novelfull", nil, tls, tls},
|
|
// unknown site: fail closed — nothing to fetch or parse
|
|
{"mangadex", browser, tls, nil},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.site, func(t *testing.T) {
|
|
if got := fetcherFor(tc.site, tc.browser, tc.tls); got != tc.want {
|
|
t.Fatalf("fetcherFor(%q) = %v, want %v", tc.site, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetchableSeriesURLPinsNovelHosts(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
site string
|
|
url string
|
|
want bool
|
|
}{
|
|
{"novelfull on its own host", "novelfull", "https://novelfull.com/reverend-insanity.html", true},
|
|
{"novelfull on a foreign host", "novelfull", "https://evil.example/x.html", false},
|
|
{"novelfull over http", "novelfull", "http://novelfull.com/x.html", false},
|
|
{"lightnovelworld on its own host", "lightnovelworld", "https://lightnovelworld.net/novel/a-will-eternal/", true},
|
|
{"lightnovelworld on a foreign host", "lightnovelworld", "https://evil.example/novel/x/", false},
|
|
{"unknown site", "webnovel", "https://webnovel.com/x", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := fetchableSeriesURL(tc.site, tc.url); got != tc.want {
|
|
t.Fatalf("fetchableSeriesURL(%q, %q) = %v, want %v", tc.site, tc.url, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A Series that has been blank since creation has no source URL to refetch.
|
|
// The poll extracts the Cover from the same series page it already fetched
|
|
// for the chapter signal and stores the bytes — every Site, both Libraries.
|
|
func TestRunOnceFillsBlankCoverFromSeriesPage(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
key string
|
|
site string
|
|
seriesID string
|
|
seriesURL string
|
|
kind string
|
|
body string
|
|
wantCover string
|
|
browser bool
|
|
}{
|
|
{
|
|
name: "asura manga",
|
|
key: "asura:chronicles-of-the-demon-faction-f886a8af", site: "asura",
|
|
seriesID: "chronicles-of-the-demon-faction-f886a8af",
|
|
seriesURL: "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af",
|
|
kind: store.KindManga, body: asuraSeriesFixture + asuraCoverFixture,
|
|
wantCover: "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp",
|
|
},
|
|
{
|
|
name: "lightnovelworld novel",
|
|
key: "lightnovelworld:all-jobs-and-classes-i-just-wanted-one-skill-not-them-all", site: "lightnovelworld",
|
|
seriesID: "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all", seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
|
kind: store.KindNovel, body: lnwSeriesFixture + lnwCoverFixture,
|
|
wantCover: "https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg",
|
|
},
|
|
{
|
|
name: "kagane manga",
|
|
key: "kagane:019fe11a-8670-7cf3-8343-0b02057d3787", site: "kagane",
|
|
seriesID: "019fe11a-8670-7cf3-8343-0b02057d3787",
|
|
seriesURL: "https://kagane.to/series/019fe11a-8670-7cf3-8343-0b02057d3787",
|
|
kind: store.KindManga, body: kaganeAPIFixtureWithCover, browser: true,
|
|
wantCover: "https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: tc.key, Site: tc.site, SeriesID: tc.seriesID,
|
|
SeriesURL: tc.seriesURL, Kind: tc.kind, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
page := &fakeFetcher{body: tc.body, status: 200}
|
|
public := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
|
|
browser := &fakeCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"}
|
|
p := &Poller{
|
|
Store: s, Fetch: page, BrowserFetch: page,
|
|
CoverBytesFetch: public, CoverFetch: browser,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
got := readBookmark(t, s, tc.key)
|
|
wantWire := testCoverBaseURL + "/covers/" + store.CoverAddress(tc.wantCover)
|
|
if got.Cover != wantWire {
|
|
t.Fatalf("Cover = %q, want %q", got.Cover, wantWire)
|
|
}
|
|
if tc.browser {
|
|
if got := browser.callCount(); got != 1 {
|
|
t.Fatalf("browser cover fetches = %d, want 1", got)
|
|
}
|
|
if got := public.callCount(); got != 0 {
|
|
t.Fatalf("public cover fetches = %d, want 0", got)
|
|
}
|
|
} else {
|
|
if got := public.callCount(); got != 1 {
|
|
t.Fatalf("public cover fetches = %d, want 1", got)
|
|
}
|
|
if got := browser.callCount(); got != 0 {
|
|
t.Fatalf("browser cover fetches = %d, want 0", got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Once a Cover exists the poll must leave it alone: refetching every cycle is
|
|
// noise for the Reader and a request per Series against Sites that already
|
|
// bot-score the deployment's single IP.
|
|
func TestRunOnceDoesNotReplaceExistingCover(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
const (
|
|
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
|
seriesID = "chronicles-of-the-demon-faction-f886a8af"
|
|
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
|
first = "https://cdn.example/covers/first.jpg"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := s.SetSeriesCover("asura", seriesID, first, []byte("first"), "image/jpeg"); err != nil {
|
|
t.Fatalf("seed cover: %v", err)
|
|
}
|
|
public := &fakeBytesCoverFetcher{body: []byte("second"), contentType: "image/jpeg"}
|
|
at := time.UnixMilli(5_000_000)
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
|
|
CoverBytesFetch: public,
|
|
Now: func() time.Time { return at },
|
|
}
|
|
p.runOnce(context.Background())
|
|
at = at.Add(2 * time.Hour)
|
|
p.runOnce(context.Background())
|
|
|
|
if got := public.callCount(); got != 0 {
|
|
t.Fatalf("cover fetch calls = %d, want 0", got)
|
|
}
|
|
got := readBookmark(t, s, key)
|
|
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(first); got.Cover != want {
|
|
t.Fatalf("Cover = %q, want the first one %q", got.Cover, want)
|
|
}
|
|
}
|
|
|
|
// A blank Cover whose byte fetch fails is retried the next time the Series is
|
|
// polled. There is no separate retry queue — the due cycle is the queue.
|
|
func TestRunOnceRetriesFailedBlankCoverOnNextPoll(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
const (
|
|
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
|
seriesID = "chronicles-of-the-demon-faction-f886a8af"
|
|
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
|
coverURL = "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
public := &fakeBytesCoverFetcher{err: errors.New("cdn down")}
|
|
at := time.UnixMilli(5_000_000)
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
|
|
CoverBytesFetch: public,
|
|
Now: func() time.Time { return at },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
if got := readBookmark(t, s, key); got.Cover != "" {
|
|
t.Fatalf("Cover after failed fetch = %q, want blank", got.Cover)
|
|
}
|
|
if got := public.callCount(); got != 1 {
|
|
t.Fatalf("cover fetch calls after fail = %d, want 1", got)
|
|
}
|
|
|
|
public.err = nil
|
|
public.body = []byte("cover-bytes")
|
|
public.contentType = "image/jpeg"
|
|
at = at.Add(2 * time.Hour)
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
if got := public.callCount(); got != 2 {
|
|
t.Fatalf("cover fetch calls after retry = %d, want 2", got)
|
|
}
|
|
got := readBookmark(t, s, key)
|
|
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(coverURL); got.Cover != want {
|
|
t.Fatalf("Cover after retry = %q, want %q", got.Cover, want)
|
|
}
|
|
}
|
|
|
|
// Cover work is cosmetic: a failed blank fill must leave the chapter poll's
|
|
// result intact for every Site, not only kagane.
|
|
func TestRunOnceBlankCoverFailureDoesNotBlockChapter(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
const (
|
|
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
|
seriesID = "chronicles-of-the-demon-faction-f886a8af"
|
|
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
|
)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
now := time.UnixMilli(5_000_000)
|
|
var logs strings.Builder
|
|
prev := log.Writer()
|
|
log.SetOutput(&logs)
|
|
t.Cleanup(func() { log.SetOutput(prev) })
|
|
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
|
|
CoverBytesFetch: &fakeBytesCoverFetcher{err: errors.New("cdn down")},
|
|
Now: func() time.Time { return now },
|
|
}
|
|
p.runOnce(context.Background())
|
|
p.waitCovers()
|
|
|
|
got := readBookmark(t, s, key)
|
|
if got.LatestChapterNum == nil || *got.LatestChapterNum != 181 {
|
|
t.Fatalf("LatestChapterNum = %v, want 181", got.LatestChapterNum)
|
|
}
|
|
if got.Cover != "" {
|
|
t.Fatalf("Cover = %q, want blank after failed fetch", got.Cover)
|
|
}
|
|
if !strings.Contains(logs.String(), key) {
|
|
t.Fatalf("cover failure log missing series key %q; got %q", key, logs.String())
|
|
}
|
|
}
|
|
|
|
// kaganeAPIFixture carries chapter data only. The blank-fill path needs a
|
|
// cover image id in the same body the chapter poll already retrieved.
|
|
const kaganeAPIFixtureWithCover = `
|
|
{"series_id":"019fe11a-8670-7cf3-8343-0b02057d3787","title":"Infinite Decryption",
|
|
"series_covers":[{"cover_id":"019fe11a-84d1-714b-9cf4-2827f277f3c0","language":"en","volume_number":"1","chapter_number":null,"note":null,"image_id":"019fe11a-84c3-7fc3-a84b-88787374b617"}],
|
|
"series_books":[{"book_id":"a","title":"Episode 1","chapter_no":"1","sort_no":1},
|
|
{"book_id":"b","title":"Episode 41","chapter_no":"41","sort_no":41},
|
|
{"book_id":"c","title":"Episode 40.5","chapter_no":"40.5","sort_no":40}]}
|
|
`
|
|
|
|
func TestEffectiveGap(t *testing.T) {
|
|
asura := sites["asura"]
|
|
tests := []struct {
|
|
eligible int
|
|
want time.Duration
|
|
clamped bool
|
|
}{
|
|
{0, 10 * time.Second, false},
|
|
{5, 10 * time.Second, false},
|
|
{360, 10 * time.Second, false},
|
|
{720, 5 * time.Second, false},
|
|
{3600, time.Second, false},
|
|
{4000, time.Second, true},
|
|
}
|
|
for _, tt := range tests {
|
|
got, clamped := effectiveGap(asura, tt.eligible)
|
|
if got != tt.want || clamped != tt.clamped {
|
|
t.Errorf("effectiveGap(asura, %d) = (%s, %v), want (%s, %v)",
|
|
tt.eligible, got, clamped, tt.want, tt.clamped)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The due query orders by sharedness first, then age: a series two readers
|
|
// track is polled before a single-reader series that has waited far longer
|
|
// (ADR-0003, issue #100).
|
|
func TestRunOnceOrdersBySharednessThenAge(t *testing.T) {
|
|
s, url := newTestStore(t)
|
|
other, err := store.Open(url, store.Owner{DiscordID: "second-reader", TokenHash: sha256.Sum256([]byte("second-token-hash"))}, t.TempDir(), testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open second reader: %v", err)
|
|
}
|
|
t.Cleanup(func() { other.Close() })
|
|
|
|
now := time.UnixMilli(5_000_000)
|
|
// popular: two readers, due for 2 minutes. loner: one reader, due for 23
|
|
// minutes. Popularity must win — the loner waited far longer.
|
|
const (
|
|
popularKey = "asura:popular"
|
|
lonerKey = "asura:loner"
|
|
popularURL = "https://asurascans.com/comics/popular"
|
|
lonerURL = "https://asurascans.com/comics/loner"
|
|
)
|
|
seedForCheck(t, s, popularKey, popularURL, now.Add(-62*time.Minute).UnixMilli())
|
|
seedForCheck(t, s, lonerKey, lonerURL, 0)
|
|
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
|
|
Key: popularKey, Site: "asura", SeriesID: "popular", UpdatedAt: 2000,
|
|
}); err != nil {
|
|
t.Fatalf("seed second reader: %v", err)
|
|
}
|
|
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
newTestPoller(t, s, f, now).runOnce(context.Background())
|
|
|
|
if len(f.calls) != 2 {
|
|
t.Fatalf("fetches = %d, want 2", len(f.calls))
|
|
}
|
|
if f.calls[0] != popularURL {
|
|
t.Fatalf("first fetch = %q, want the shared series %q", f.calls[0], popularURL)
|
|
}
|
|
}
|
|
|
|
// Two refusals in one pass stop the Lane: the remaining Series stay unstamped
|
|
// and due, and the Lane backs off for refuseBackoff before trying the Site
|
|
// again. One hostile Site burns only its own Lane's budget (issue #100).
|
|
func TestRunOnceSiteRefusalSkipsRestOfLaneAndBacksOff(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(5_000_000)
|
|
const n = 4
|
|
for i := 0; i < n; i++ {
|
|
key := fmt.Sprintf("kagane:s%d", i)
|
|
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0)
|
|
}
|
|
|
|
browser := &fakeFetcher{status: 403}
|
|
tls := &fakeFetcher{status: 200}
|
|
// An asura Series sits on its own Lane: it must still be polled while
|
|
// kagane's Lane burns its budget on refusals (story 4).
|
|
seedForCheck(t, s, "asura:still-polls", "https://asurascans.com/series/still-polls", 0)
|
|
p := &Poller{
|
|
Store: s, Fetch: tls, BrowserFetch: browser,
|
|
Now: func() time.Time { return now },
|
|
}
|
|
|
|
p.runOnce(context.Background())
|
|
if got := browser.callCount(); got != 2 {
|
|
t.Fatalf("browser fetches after first pass = %d, want 2 (refused twice)", got)
|
|
}
|
|
if got := tls.callCount(); got != 1 {
|
|
t.Fatalf("asura fetches after first pass = %d, want 1 (its Lane is independent)", got)
|
|
}
|
|
stamped := 0
|
|
for i := 0; i < n; i++ {
|
|
if readLatestCheckedAt(t, s, fmt.Sprintf("kagane:s%d", i)) == now.UnixMilli() {
|
|
stamped++
|
|
}
|
|
}
|
|
if stamped != 2 {
|
|
t.Fatalf("stamped series = %d, want 2; the remaining two stay due", stamped)
|
|
}
|
|
|
|
// Inside the backoff window nothing is attempted.
|
|
p.Now = func() time.Time { return now.Add(14 * time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if got := browser.callCount(); got != 2 {
|
|
t.Fatalf("browser fetches inside backoff = %d, want still 2", got)
|
|
}
|
|
|
|
// Past the backoff the Lane resumes and the two untried Series are polled.
|
|
p.Now = func() time.Time { return now.Add(16 * time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if got := browser.callCount(); got != 4 {
|
|
t.Fatalf("browser fetches after backoff = %d, want 4", got)
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
if got := readLatestCheckedAt(t, s, fmt.Sprintf("kagane:s%d", i)); got == 0 {
|
|
t.Fatalf("kagane:s%d still untried after backoff", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Below five due Series with none waiting long, a browser Lane leaves Chrome
|
|
// asleep; five due, or one waiting browserWakeAge, wakes it (ADR-0005).
|
|
func TestBrowserLaneWakeThresholds(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(5_000_000)
|
|
// Freshly due: checked two minutes before the rest elapses, so the wait
|
|
// is far below browserWakeAge.
|
|
seed := func(i int) {
|
|
key := fmt.Sprintf("kagane:w%d", i)
|
|
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], now.Add(-62*time.Minute).UnixMilli())
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
seed(i)
|
|
}
|
|
|
|
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
|
|
Now: func() time.Time { return now },
|
|
}
|
|
|
|
p.runOnce(context.Background())
|
|
if got := browser.callCount(); got != 0 {
|
|
t.Fatalf("browser fetches with 3 freshly-due series = %d, want 0 (Chrome stays asleep)", got)
|
|
}
|
|
// 5 due crosses the count threshold.
|
|
for i := 3; i < 5; i++ {
|
|
seed(i)
|
|
}
|
|
p.runOnce(context.Background())
|
|
if got := browser.callCount(); got != 5 {
|
|
t.Fatalf("browser fetches with 5 due series = %d, want 5", got)
|
|
}
|
|
// A single long-neglected series wakes the browser by age alone.
|
|
seedForCheck(t, s, "kagane:ancient", "https://kagane.to/series/ancient", 0)
|
|
p.runOnce(context.Background())
|
|
if got := browser.callCount(); got != 6 {
|
|
t.Fatalf("browser fetches with one ancient series = %d, want 6", got)
|
|
}
|
|
}
|
|
|
|
// When one browser Lane loses the sidecar, the round's remaining browser
|
|
// Lanes are skipped: every fetch would fail anyway, and their Series must not
|
|
// burn their stamps on a dead Chrome (issue #100).
|
|
func TestRunOnceUnreachableBrowserStopsBrowserLanes(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(5_000_000)
|
|
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
|
seedForCheck(t, s, "kagane:k", "https://kagane.to/series/k", 0)
|
|
seedForCheck(t, s, "novelfull:n", "https://novelfull.com/n.html", 0)
|
|
|
|
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
|
|
browser := &fakeFetcher{status: 200, err: interrupted}
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
|
|
Now: func() time.Time { return now },
|
|
}
|
|
p.runOnce(context.Background())
|
|
|
|
// Sorted lane order: comix, kagane, novelfull. Only comix attempted.
|
|
if got := browser.callCount(); got != 1 {
|
|
t.Fatalf("browser fetches = %d, want 1 (only the first browser lane)", got)
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "comix:c"); got != now.UnixMilli() {
|
|
t.Fatalf("comix stamp = %d, want %d", got, now.UnixMilli())
|
|
}
|
|
for _, key := range []string{"kagane:k", "novelfull:n"} {
|
|
if got := readLatestCheckedAt(t, s, key); got != 0 {
|
|
t.Fatalf("%s stamp = %d, want 0 (untried)", key, got)
|
|
}
|
|
}
|
|
|
|
// The shared flag decays after refuseBackoff: the next round probes
|
|
// again. comix's Series is resting (stamped last round), so the probe
|
|
// falls to kagane — the only Lane with something due — and its fresh
|
|
// loss re-gates the Lanes behind it.
|
|
p.Now = func() time.Time { return now.Add(16 * time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if got := browser.callCount(); got != 2 {
|
|
t.Fatalf("browser fetches after backoff decay = %d, want 2 (kagane probes again)", got)
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "comix:c"); got != now.UnixMilli() {
|
|
t.Fatalf("comix stamp after decay = %d, want %d (resting, untouched)", got, now.UnixMilli())
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "kagane:k"); got != now.Add(16*time.Minute).UnixMilli() {
|
|
t.Fatalf("kagane stamp after decay = %d, want %d (the probe)", got, now.Add(16*time.Minute).UnixMilli())
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "novelfull:n"); got != 0 {
|
|
t.Fatalf("novelfull stamp after decay = %d, want 0 (gated by kagane's fresh loss)", got)
|
|
}
|
|
}
|
|
|
|
// A browser Lane that cannot keep up logs the backlog every pass, so the
|
|
// decision to give browser Sites more pages is measured, not guessed.
|
|
func TestRunOnceLogsBrowserLaneBehind(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(5_000_000)
|
|
// Checked three rests ago: two rests' worth of wait past the due moment.
|
|
seedForCheck(t, s, "kagane:old", "https://kagane.to/series/old", now.Add(-3*time.Hour).UnixMilli())
|
|
|
|
var logs strings.Builder
|
|
prev := log.Writer()
|
|
log.SetOutput(&logs)
|
|
t.Cleanup(func() { log.SetOutput(prev) })
|
|
|
|
p := &Poller{
|
|
Store: s, Fetch: &fakeFetcher{status: 200},
|
|
BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
|
|
Now: func() time.Time { return now },
|
|
}
|
|
p.runOnce(context.Background())
|
|
|
|
if got := logs.String(); !strings.Contains(got, "latest poll kagane: browser lane behind by 1h0m0s") {
|
|
t.Fatalf("behind log = %q, want it to name kagane and the backlog", got)
|
|
}
|
|
}
|
|
|
|
// gatedCoverFetcher blocks every Fetch on a gate, so a test can hold a cover
|
|
// heal in flight and prove a Lane does not wait for it.
|
|
type gatedCoverFetcher struct {
|
|
inner *fakeBytesCoverFetcher
|
|
gate chan struct{}
|
|
started chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (g *gatedCoverFetcher) Fetch(ctx context.Context, sourceURL string) ([]byte, string, error) {
|
|
g.once.Do(func() { g.started <- struct{}{} })
|
|
<-g.gate
|
|
return g.inner.Fetch(ctx, sourceURL)
|
|
}
|
|
|
|
// Cover heals run in the background: a heal stuck on a slow CDN must not
|
|
// delay the Lane's next Series-page Poll, or a large import with many blanks
|
|
// would make every Latest Chapter go stale (issue #100).
|
|
func TestRunOnceCoverFetchDoesNotDelayNextPoll(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
// Two Series whose legacy cover sources still need healing.
|
|
for i := 0; i < 2; i++ {
|
|
key := fmt.Sprintf("asura:cover-%d", i)
|
|
seriesID := fmt.Sprintf("cover-%d", i)
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: key, Site: "asura", SeriesID: seriesID,
|
|
SeriesURL: "https://asurascans.com/comics/" + seriesID, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
seedCoverSource(t, dbURL, "asura", seriesID, fmt.Sprintf("https://cdn.example/covers/%d.jpg", i))
|
|
}
|
|
|
|
pages := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
gated := &gatedCoverFetcher{
|
|
inner: &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"},
|
|
gate: make(chan struct{}),
|
|
started: make(chan struct{}, 1),
|
|
}
|
|
p := &Poller{
|
|
Store: s, Fetch: pages, CoverBytesFetch: gated,
|
|
Now: func() time.Time { return time.UnixMilli(5_000_000) },
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
p.runOnce(context.Background())
|
|
close(done)
|
|
}()
|
|
<-gated.started // the first cover heal is now stuck on its CDN
|
|
select {
|
|
case <-done:
|
|
// The Lane finished its page fetches without waiting for the cover.
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("runOnce blocked on an in-flight cover fetch")
|
|
}
|
|
close(gated.gate)
|
|
p.waitCovers()
|
|
|
|
if got := pages.callCount(); got != 2 {
|
|
t.Fatalf("series page fetches = %d, want 2", got)
|
|
}
|
|
if got := gated.inner.callCount(); got != 2 {
|
|
t.Fatalf("cover fetches = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
// At 3601 eligible Series the effective gap falls below the one-second floor;
|
|
// the clamp warning must name the Site so the operator knows which Lane is
|
|
// outrunning its plan.
|
|
func TestRunOnceClampWarningNamesTheSite(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
db, err := sql.Open("pgx", dbURL)
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", dbURL, err)
|
|
}
|
|
defer db.Close()
|
|
if _, err := db.Exec(`INSERT INTO series (site, series_id, series_url, latest_checked_at)
|
|
SELECT 'asura', 'bulk-' || g, 'https://asurascans.com/comics/bulk-' || g, 0
|
|
FROM generate_series(1, 3601) AS g`); err != nil {
|
|
t.Fatalf("bulk seed series: %v", err)
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO bookmarks (reader_id, site, series_id, updated_at)
|
|
SELECT (SELECT id FROM readers ORDER BY id LIMIT 1), 'asura', 'bulk-' || g, 1000
|
|
FROM generate_series(1, 3601) AS g`); err != nil {
|
|
t.Fatalf("bulk seed bookmarks: %v", err)
|
|
}
|
|
|
|
var logs strings.Builder
|
|
prev := log.Writer()
|
|
log.SetOutput(&logs)
|
|
t.Cleanup(func() { log.SetOutput(prev) })
|
|
|
|
newTestPoller(t, s, &fakeFetcher{body: "<html></html>", status: 200}, time.UnixMilli(5_000_000)).
|
|
runOnce(context.Background())
|
|
|
|
if got := logs.String(); !strings.Contains(got, "latest poll asura: gap clamped to 1s floor (eligible series=3601)") {
|
|
t.Fatalf("clamp warning = %q, want it to name asura and 3601", got)
|
|
}
|
|
}
|
|
|
|
// The owner's admin page (issue #102) reads Lane state out of the poller.
|
|
// Before any pass the snapshot is empty — a restart must render "no data
|
|
// yet", not zeroes — and each pass records what it saw: the frozen clock,
|
|
// the due count and the pace, with refusal backoff derived at snapshot time.
|
|
func TestLaneStatus(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(5_000_000)
|
|
p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
|
|
|
if st := p.LaneStatus(); len(st.Lanes) != 0 {
|
|
t.Fatalf("lanes before any pass = %d, want 0 (nothing has run)", len(st.Lanes))
|
|
} else if st.BrowserConfigured || st.BrowserReachable {
|
|
t.Fatalf("browser before any pass = configured=%v reachable=%v, want false without a browser fetcher", st.BrowserConfigured, st.BrowserReachable)
|
|
}
|
|
|
|
seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0)
|
|
// Two refusals put kagane's Lane into backoff; asura sits on its own Lane.
|
|
browser := &fakeFetcher{status: 403}
|
|
p.BrowserFetch = browser
|
|
for i := range 2 {
|
|
key := fmt.Sprintf("kagane:s%d", i)
|
|
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0)
|
|
}
|
|
p.runOnce(context.Background())
|
|
|
|
st := p.LaneStatus()
|
|
var asura, kagane LaneState
|
|
for _, lane := range st.Lanes {
|
|
switch lane.Site {
|
|
case "asura":
|
|
asura = lane
|
|
case "kagane":
|
|
kagane = lane
|
|
}
|
|
}
|
|
if st.Lanes[0].Site != "asura" {
|
|
t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site)
|
|
}
|
|
if asura.Site == "" {
|
|
t.Fatalf("asura missing from snapshot: %+v", st.Lanes)
|
|
}
|
|
if !asura.LastRun.Equal(now) {
|
|
t.Fatalf("asura LastRun = %s, want the frozen clock %s", asura.LastRun, now)
|
|
}
|
|
if asura.Due != 1 {
|
|
t.Fatalf("asura Due = %d, want 1", asura.Due)
|
|
}
|
|
if asura.Gap == 0 {
|
|
t.Fatal("asura Gap = 0, want the Lane's pace")
|
|
}
|
|
if asura.Browser || asura.Refusing {
|
|
t.Fatalf("asura = %+v, want a TLS Lane that is not refusing", asura)
|
|
}
|
|
if !kagane.Browser || !kagane.Refusing {
|
|
t.Fatalf("kagane = %+v, want a browser Lane in refusal backoff", kagane)
|
|
}
|
|
if !st.BrowserConfigured || !st.BrowserReachable {
|
|
t.Fatalf("browser after round = configured=%v reachable=%v, want true/true (sidecar never lost)", st.BrowserConfigured, st.BrowserReachable)
|
|
}
|
|
if asura.Checked != 1 {
|
|
t.Fatalf("asura Checked = %d, want the one Series it read", asura.Checked)
|
|
}
|
|
|
|
// A pass that declines to look (kagane is now in backoff) must not restate
|
|
// the figures it never gathered as zeroes: the last real pass's due count
|
|
// and pace stand until a pass replaces them.
|
|
before := kagane
|
|
if before.Due == 0 || before.Gap == 0 {
|
|
t.Fatalf("kagane after its refusing pass = %+v, want the figures that pass gathered", before)
|
|
}
|
|
p.runOnce(context.Background())
|
|
for _, lane := range p.LaneStatus().Lanes {
|
|
if lane.Site != "kagane" {
|
|
continue
|
|
}
|
|
if lane.Due != before.Due || lane.Gap != before.Gap {
|
|
t.Fatalf("kagane after a skipped pass = due %d gap %s, want the previous pass's %d / %s",
|
|
lane.Due, lane.Gap, before.Due, before.Gap)
|
|
}
|
|
}
|
|
|
|
// A lost sidecar reads as unreachable for the same window the Lanes skip.
|
|
p.setBrowserDown(now)
|
|
if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable {
|
|
t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable)
|
|
}
|
|
}
|