Record poll failures as rows: the row is the failure state (#165)
This commit is contained in:
@@ -2725,3 +2725,218 @@ 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) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("pgx", dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dbURL, err)
|
||||
}
|
||||
defer db.Close()
|
||||
err = db.QueryRow(
|
||||
`SELECT outcome, failing_since FROM poll_failures WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID).Scan(&word, &since)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", 0, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read failure row %s:%s: %v", site, seriesID, err)
|
||||
}
|
||||
return word, since, true
|
||||
}
|
||||
|
||||
// The failure row follows the read (ADR-0016): a 404 writes a row with the
|
||||
// outcome word and the pass's time; a second 404 an hour later leaves
|
||||
// failing_since alone — the age is the age of the run of failures, not of
|
||||
// the current word; a good read deletes the row.
|
||||
func TestRunLanePassFailureRowFollowsTheRead(t *testing.T) {
|
||||
s, dbURL := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
const (
|
||||
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||
)
|
||||
seedForCheck(t, s, key, seriesURL, 0)
|
||||
|
||||
f := &fakeFetcher{perURL: map[string]fakeResponse{seriesURL: {status: 404}}}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
|
||||
word, since, found := failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af")
|
||||
if !found || word != "not_found" || since != now.UnixMilli() {
|
||||
t.Fatalf("row after 404 = (%q, %d, %v), want (not_found, %d, true)", word, since, found, now.UnixMilli())
|
||||
}
|
||||
|
||||
// A repeated failure an hour later: the word is unchanged and the stamp
|
||||
// is untouched, so the age keeps meaning the run of failures.
|
||||
p.Now = func() time.Time { return now.Add(2 * time.Hour) }
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
word, since, found = failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af")
|
||||
if !found || word != "not_found" || since != now.UnixMilli() {
|
||||
t.Fatalf("row after repeated 404 = (%q, %d, %v), want (not_found, %d, true)", word, since, found, now.UnixMilli())
|
||||
}
|
||||
|
||||
// A good read ends the failure: the row is gone.
|
||||
f.perURL[seriesURL] = fakeResponse{body: asuraSeriesFixture, status: 200}
|
||||
p.Now = func() time.Time { return now.Add(4 * time.Hour) }
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if _, _, found := failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af"); found {
|
||||
t.Fatal("failure row after a good read: still present")
|
||||
}
|
||||
}
|
||||
|
||||
// Every failure word the pass counts lands in the table under the wire
|
||||
// spelling the worklist left-joins on (C1): no_chapter, unfetchable, errors,
|
||||
// not_found — and a success writes no row.
|
||||
func TestRunLanePassStoresEachFailureWord(t *testing.T) {
|
||||
s, dbURL := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
seeds := map[string]string{
|
||||
"asura:no-chapter": "https://asurascans.com/comics/no-chapter",
|
||||
"asura:unfetchable": "https://evil.example/x",
|
||||
"asura:transport": "https://asurascans.com/series/transport",
|
||||
"asura:gone": "https://asurascans.com/series/gone",
|
||||
"asura:healthy": "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af",
|
||||
}
|
||||
for key, url := range seeds {
|
||||
seedForCheck(t, s, key, url, 0)
|
||||
}
|
||||
f := &fakeFetcher{perURL: map[string]fakeResponse{
|
||||
seeds["asura:no-chapter"]: {body: "<html></html>", status: 200},
|
||||
seeds["asura:transport"]: {err: errors.New("dial tcp: refused")},
|
||||
seeds["asura:gone"]: {status: 404},
|
||||
seeds["asura:healthy"]: {body: asuraSeriesFixture, status: 200},
|
||||
}}
|
||||
newTestPoller(t, s, f, now).runLanePass(context.Background(), "asura", false)
|
||||
|
||||
want := map[string]string{
|
||||
"no-chapter": "no_chapter",
|
||||
"unfetchable": "unfetchable",
|
||||
"transport": "errors",
|
||||
"gone": "not_found",
|
||||
}
|
||||
for seriesID, word := range want {
|
||||
got, _, found := failureRow(t, dbURL, "asura", seriesID)
|
||||
if !found || got != word {
|
||||
t.Fatalf("failure word for %s = (%q, %v), want %q", seriesID, got, found, word)
|
||||
}
|
||||
}
|
||||
if _, _, found := failureRow(t, dbURL, "asura", "healthy"); found {
|
||||
t.Fatal("success Series gained a failure row")
|
||||
}
|
||||
}
|
||||
|
||||
// refused and unreachable issue no statement at all (ADR-0016): a challenge
|
||||
// or a lost sidecar is no evidence about any particular Series, so a
|
||||
// pre-seeded row survives both untouched and a Series with no row gains none.
|
||||
func TestRunLanePassRefusalAndUnreachableWriteNothing(t *testing.T) {
|
||||
s, dbURL := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
|
||||
t.Run("refused", func(t *testing.T) {
|
||||
const (
|
||||
seeded = "asura:held"
|
||||
neverFail = "asura:other"
|
||||
)
|
||||
seedForCheck(t, s, seeded, "https://asurascans.com/series/held", 0)
|
||||
seedForCheck(t, s, neverFail, "https://asurascans.com/series/other", 0)
|
||||
if err := s.RecordSeriesFailure("asura", "held", "not_found", 7_000); err != nil {
|
||||
t.Fatalf("seed failure row: %v", err)
|
||||
}
|
||||
f := &fakeFetcher{perURL: map[string]fakeResponse{
|
||||
"https://asurascans.com/series/held": {status: 403},
|
||||
"https://asurascans.com/series/other": {status: 403},
|
||||
}}
|
||||
newTestPoller(t, s, f, now).runLanePass(context.Background(), "asura", false)
|
||||
|
||||
word, since, found := failureRow(t, dbURL, "asura", "held")
|
||||
if !found || word != "not_found" || since != 7_000 {
|
||||
t.Fatalf("pre-seeded row after refusal = (%q, %d, %v), want (not_found, 7000, true)", word, since, found)
|
||||
}
|
||||
if _, _, found := failureRow(t, dbURL, "asura", "other"); found {
|
||||
t.Fatal("row appeared for a refused Series with none: refusal must write nothing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unreachable mid-loop", func(t *testing.T) {
|
||||
const (
|
||||
seeded = "comix:c"
|
||||
neverFail = "comix:d"
|
||||
)
|
||||
seedForCheck(t, s, seeded, "https://comix.to/title/c", 0)
|
||||
seedForCheck(t, s, neverFail, "https://comix.to/title/d", 0)
|
||||
if err := s.RecordSeriesFailure("comix", "c", "errors", 7_000); err != nil {
|
||||
t.Fatalf("seed failure row: %v", err)
|
||||
}
|
||||
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)
|
||||
|
||||
word, since, found := failureRow(t, dbURL, "comix", "c")
|
||||
if !found || word != "errors" || since != 7_000 {
|
||||
t.Fatalf("pre-seeded row after unreachable = (%q, %d, %v), want (errors, 7000, true)", word, since, found)
|
||||
}
|
||||
if _, _, found := failureRow(t, dbURL, "comix", "d"); found {
|
||||
t.Fatal("row appeared for an unreachable Series with none: unreachable must write nothing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A Forced Poll request clears nothing — it only stamps the request — and a
|
||||
// forced Poll that then reads the page clears the row through the ordinary
|
||||
// success path, with no forced branch in the code.
|
||||
func TestRunLanePassForcedPollClearsThroughSuccess(t *testing.T) {
|
||||
s, dbURL := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
const (
|
||||
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||
)
|
||||
seedForCheck(t, s, key, seriesURL, 0)
|
||||
if err := s.RecordSeriesFailure("asura", "chronicles-of-the-demon-faction-f886a8af", "not_found", 7_000); err != nil {
|
||||
t.Fatalf("seed failure row: %v", err)
|
||||
}
|
||||
if err := s.ForceSeriesPoll("asura", "chronicles-of-the-demon-faction-f886a8af", now.Add(time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||
}
|
||||
// The request alone cleared nothing.
|
||||
if _, _, found := failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af"); !found {
|
||||
t.Fatal("failure row gone after only a Forced Poll request")
|
||||
}
|
||||
|
||||
newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now).
|
||||
runLanePass(context.Background(), "asura", false)
|
||||
if _, _, found := failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af"); found {
|
||||
t.Fatal("failure row after a forced Poll that read the page: still present")
|
||||
}
|
||||
}
|
||||
|
||||
// The due query does not join the failure table (AC7): a failing Series is
|
||||
// polled at the same pace as any other, so its failure age keeps meaning what
|
||||
// the worklist reads it as.
|
||||
func TestPollFailureDoesNotChangePacing(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
seedForCheck(t, s, "asura:failing", "https://asurascans.com/series/failing", 0)
|
||||
seedForCheck(t, s, "asura:healthy", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
|
||||
f := &fakeFetcher{perURL: map[string]fakeResponse{
|
||||
"https://asurascans.com/series/failing": {status: 404},
|
||||
"https://asurascans.com/series/healthy": {body: asuraSeriesFixture, status: 200},
|
||||
}}
|
||||
p := newTestPoller(t, s, f, now)
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := f.callCount(); got != 2 {
|
||||
t.Fatalf("first pass fetched %d series, want both (the failure row must not exclude the failing one)", got)
|
||||
}
|
||||
|
||||
// After the rest both are due again: the row the first pass wrote changed
|
||||
// nothing about when the failing Series is polled.
|
||||
p.Now = func() time.Time { return now.Add(2 * time.Hour) }
|
||||
p.runLanePass(context.Background(), "asura", false)
|
||||
if got := f.callCount(); got != 4 {
|
||||
t.Fatalf("second pass fetched %d series, want both again", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user