Merge #169: The pass remembers when a Site called a work completed
# Conflicts: # backend/internal/latest/poller_test.go
This commit is contained in:
@@ -2725,6 +2725,7 @@ func TestRunOnceForcedPassReclaimFailureDoesNotFailPoll(t *testing.T) {
|
||||
t.Fatalf("Cover = %q, want the replacement %q", got.Cover, want)
|
||||
}
|
||||
}
|
||||
|
||||
// failureRow reads one Series' failure row as stored, for the poller tests
|
||||
// that must see the table the store API writes (ADR-0016).
|
||||
func failureRow(t *testing.T, dbURL, site, seriesID string) (word string, since int64, found bool) {
|
||||
@@ -2940,3 +2941,154 @@ func TestPollFailureDoesNotChangePacing(t *testing.T) {
|
||||
t.Fatalf("second pass fetched %d series, want both again", got)
|
||||
}
|
||||
}
|
||||
|
||||
// readSiteCompletedAt reads the poller's site_completed_at column directly:
|
||||
// it is a poller fact with no client-visible getter, and #170 is the surface
|
||||
// that will read it.
|
||||
func readSiteCompletedAt(t *testing.T, dbURL, site, seriesID string) int64 {
|
||||
t.Helper()
|
||||
db, err := sql.Open("pgx", dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dbURL, err)
|
||||
}
|
||||
defer db.Close()
|
||||
var at int64
|
||||
if err := db.QueryRow(
|
||||
`SELECT site_completed_at FROM series WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID).Scan(&at); err != nil {
|
||||
t.Fatalf("read site_completed_at %s:%s: %v", site, seriesID, err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// seedSiteCompletedAt writes the column directly, for the refused/unreachable
|
||||
// tests that need a pre-existing stamp.
|
||||
func seedSiteCompletedAt(t *testing.T, dbURL, site, seriesID string, at int64) {
|
||||
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 site_completed_at = $3 WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, at); err != nil {
|
||||
t.Fatalf("seed site_completed_at %s:%s: %v", site, seriesID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A completed page stamps site_completed_at with the pass's own clock; the
|
||||
// same page an hour later is due again but must not move the stamp — the age
|
||||
// #170 prints is "since the Site first said so", not the age of the last
|
||||
// Poll. The transition is zero-versus-nonzero, so the exact value asserted
|
||||
// here is load-bearing.
|
||||
func TestRunOnceSiteCompletedStampsOnce(t *testing.T) {
|
||||
s, dbURL := 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"
|
||||
)
|
||||
completed := asuraSeriesFixture + asuraCompletedFixture
|
||||
at := time.UnixMilli(5_000_000)
|
||||
seedForCheck(t, s, key, seriesURL, at.Add(-time.Hour).UnixMilli())
|
||||
|
||||
newTestPoller(t, s, &fakeFetcher{body: completed, status: 200}, at).runOnce(context.Background())
|
||||
if got := readSiteCompletedAt(t, dbURL, "asura", seriesID); got != at.UnixMilli() {
|
||||
t.Fatalf("site_completed_at after completed poll = %d, want %d", got, at.UnixMilli())
|
||||
}
|
||||
|
||||
hourLater := at.Add(time.Hour)
|
||||
newTestPoller(t, s, &fakeFetcher{body: completed, status: 200}, hourLater).runOnce(context.Background())
|
||||
if got := readSiteCompletedAt(t, dbURL, "asura", seriesID); got != at.UnixMilli() {
|
||||
t.Fatalf("site_completed_at after a second completed poll = %d, want the original stamp %d", got, at.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
// An ongoing page is the inverse transition: a never-hinted Series stays zero
|
||||
// (an ordinary Poll of an ongoing Series writes nothing), and a hinted one is
|
||||
// zeroed — the Site no longer says completed, so the claim must not outlive
|
||||
// the evidence.
|
||||
func TestRunOnceOngoingPageClearsOrSkipsSiteCompleted(t *testing.T) {
|
||||
s, dbURL := 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"
|
||||
)
|
||||
ongoing := asuraSeriesFixture + asuraOngoingFixture
|
||||
at := time.UnixMilli(5_000_000)
|
||||
seedForCheck(t, s, key, seriesURL, at.Add(-time.Hour).UnixMilli())
|
||||
|
||||
newTestPoller(t, s, &fakeFetcher{body: ongoing, status: 200}, at).runOnce(context.Background())
|
||||
if got := readSiteCompletedAt(t, dbURL, "asura", seriesID); got != 0 {
|
||||
t.Fatalf("site_completed_at after ongoing poll of a never-hinted series = %d, want 0", got)
|
||||
}
|
||||
|
||||
seedSiteCompletedAt(t, dbURL, "asura", seriesID, at.UnixMilli())
|
||||
hourLater := at.Add(time.Hour)
|
||||
newTestPoller(t, s, &fakeFetcher{body: ongoing, status: 200}, hourLater).runOnce(context.Background())
|
||||
if got := readSiteCompletedAt(t, dbURL, "asura", seriesID); got != 0 {
|
||||
t.Fatalf("site_completed_at after ongoing poll of a hinted series = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused or unreachable read reaches nothing after checkOne's error switch:
|
||||
// a challenge is not evidence about the work, so a pre-seeded stamp must
|
||||
// survive both.
|
||||
func TestRunOnceRefusedOrUnreachableLeavesSiteCompletedUntouched(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000)
|
||||
|
||||
t.Run("refused", func(t *testing.T) {
|
||||
s, dbURL := 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"
|
||||
)
|
||||
seedForCheck(t, s, key, seriesURL, 0)
|
||||
seedSiteCompletedAt(t, dbURL, "asura", seriesID, now.UnixMilli())
|
||||
newTestPoller(t, s, &fakeFetcher{status: 403}, now).runOnce(context.Background())
|
||||
if got := readSiteCompletedAt(t, dbURL, "asura", seriesID); got != now.UnixMilli() {
|
||||
t.Fatalf("site_completed_at after refused poll = %d, want the pre-seeded %d", got, now.UnixMilli())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mid-loop unreachable", func(t *testing.T) {
|
||||
s, dbURL := newTestStore(t)
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
seedSiteCompletedAt(t, dbURL, "comix", "c", now.UnixMilli())
|
||||
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
|
||||
browser := &fakeFetcher{status: 200, err: interrupted}
|
||||
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||
p.BrowserFetch = browser
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := readSiteCompletedAt(t, dbURL, "comix", "c"); got != now.UnixMilli() {
|
||||
t.Fatalf("site_completed_at after unreachable poll = %d, want the pre-seeded %d", got, now.UnixMilli())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The regression AC2's second sentence exists to prevent: the completed write
|
||||
// must not ride the chapter setter, so a completed page whose chapter number
|
||||
// is unchanged — checkOne's equality early return — still stamps the column.
|
||||
func TestRunOnceSiteCompletedWritesDespiteUnchangedChapter(t *testing.T) {
|
||||
s, dbURL := 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"
|
||||
)
|
||||
completed := asuraSeriesFixture + asuraCompletedFixture
|
||||
at := time.UnixMilli(5_000_000)
|
||||
seedForCheck(t, s, key, seriesURL, 0)
|
||||
// The page parses Chapter 181 as the latest; store the same number so the
|
||||
// equality early return fires and only the completed write can run.
|
||||
if err := s.SetLatestChapter("asura", seriesID, "Chapter 181", 181); err != nil {
|
||||
t.Fatalf("seed latest chapter: %v", err)
|
||||
}
|
||||
newTestPoller(t, s, &fakeFetcher{body: completed, status: 200}, at).runOnce(context.Background())
|
||||
if got := readSiteCompletedAt(t, dbURL, "asura", seriesID); got != at.UnixMilli() {
|
||||
t.Fatalf("site_completed_at after unchanged-chapter completed poll = %d, want %d", got, at.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user