Record poll failures as rows: the row is the failure state (#165)

This commit is contained in:
2026-08-22 23:22:57 +07:00
parent 4708703044
commit 417f809d75
6 changed files with 530 additions and 0 deletions
+89
View File
@@ -2611,3 +2611,92 @@ func TestRemoveSeriesMissingKeyIsCleanNoOp(t *testing.T) {
t.Fatalf("RemoveSeries on a missing key = %v, want nil", err)
}
}
// The failure row's existence is the failure state (ADR-0016):
// RecordSeriesFailure upserts the outcome word, keeps the original
// failing_since through a word change, and an unchanged word writes nothing
// at all — a broken Series costs the same as a healthy one every hour.
func TestRecordSeriesFailureUpsertKeepsFailingSince(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := s.RecordSeriesFailure("asura", "solo", "not_found", 1000); err != nil {
t.Fatalf("record: %v", err)
}
word, since, found := failureRow(t, s, "asura", "solo")
if !found || word != "not_found" || since != 1000 {
t.Fatalf("row after create = (%q, %d, %v), want (not_found, 1000, true)", word, since, found)
}
// A changed word updates the outcome and never touches failing_since.
if err := s.RecordSeriesFailure("asura", "solo", "errors", 2000); err != nil {
t.Fatalf("record changed word: %v", err)
}
word, since, found = failureRow(t, s, "asura", "solo")
if !found || word != "errors" || since != 1000 {
t.Fatalf("row after word change = (%q, %d, %v), want (errors, 1000, true)", word, since, found)
}
// An identical word writes nothing: the stamp survives a later now.
if err := s.RecordSeriesFailure("asura", "solo", "errors", 3000); err != nil {
t.Fatalf("record identical word: %v", err)
}
word, since, found = failureRow(t, s, "asura", "solo")
if !found || word != "errors" || since != 1000 {
t.Fatalf("row after identical word = (%q, %d, %v), want the earlier (errors, 1000, true)", word, since, found)
}
}
// ClearSeriesFailure deletes the row, and a row that is not there is not an
// error — the poller clears on every successful read, so most clears find
// nothing.
func TestClearSeriesFailure(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := s.ClearSeriesFailure("asura", "solo"); err != nil {
t.Fatalf("clear a missing row: %v, want nil", err)
}
if err := s.RecordSeriesFailure("asura", "solo", "not_found", 1000); err != nil {
t.Fatalf("record: %v", err)
}
if err := s.ClearSeriesFailure("asura", "solo"); err != nil {
t.Fatalf("clear: %v", err)
}
if _, _, found := failureRow(t, s, "asura", "solo"); found {
t.Fatal("row after clear: still present")
}
}
// Deleting a Series takes its failure row with it (the composite FK's
// cascade), so orphan removal stays a single statement.
func TestRemoveSeriesCascadesToFailureRow(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := s.RecordSeriesFailure("asura", "solo", "not_found", 1000); err != nil {
t.Fatalf("record: %v", err)
}
if err := s.Delete(s.OwnerID(), "asura:solo"); err != nil {
t.Fatalf("delete bookmark: %v", err)
}
if err := s.RemoveSeries("asura", "solo"); err != nil {
t.Fatalf("RemoveSeries: %v", err)
}
if _, _, found := failureRow(t, s, "asura", "solo"); found {
t.Fatal("failure row after series delete: still present (no cascade)")
}
}
// failureRow reads one Series' failure row as stored.
func failureRow(t *testing.T, s *Store, site, seriesID string) (word string, since int64, found bool) {
t.Helper()
err := s.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
}