1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
package logic

import (
	"fmt"
	"net/http"
	"rss_parrot/dal"
	"rss_parrot/shared"
	"testing"
	"time"
)

// These tests run single feed checks, each answered by a hand-written
// response, through the simulation harness (sim_harness_test.go): on the
// simulated clock, the wait a check schedules is exact, not approximate.

const emptyRss = `<?xml version="1.0"?>
<rss version="2.0"><channel><title>t</title><link>https://example.org</link></channel></rss>`

const testHandle = "example.org"

type fetchHarness struct {
	*sim
	feed *simFeed
}

// newFetchHarness sets up a database holding one followed feed account that
// points at a test server and is due now. Every schedule interval is 1h, so
// the normal wait after a check is 1h ±20%.
func newFetchHarness(t *testing.T, maxRetryDelayHours int) *fetchHarness {
	s := newSim(t, func(cfg *shared.Config) {
		cfg.MaxRetryDelayHours = maxRetryDelayHours
		cfg.UpdateSchedule = shared.UpdateSchedule{Day: 1, Week: 1, Weeks4: 1, Older: 1}
	})
	feed := newSimFeed(t, s.clock)
	if _, err := s.repo.AddAccountIfNotExist(&dal.Account{
		CreatedAt: s.clock.Now(),
		Handle:    testHandle,
		UserUrl:   "https://kakapo.example.com/u/" + testHandle,
		FeedUrl:   feed.URL,
	}, ""); err != nil {
		t.Fatal(err)
	}
	s.addFollower(testHandle)
	acct, err := s.repo.GetAccount(testHandle)
	if err != nil {
		t.Fatal(err)
	}
	// A new account is not due until 2100; make it due now.
	if err := s.repo.UpdateAccountFeedTimes(acct.Id, acct.FeedLastUpdated, s.clock.Now()); err != nil {
		t.Fatal(err)
	}
	return &fetchHarness{sim: s, feed: feed}
}

// check waits until the feed is due, runs that one check, answered by
// respond, and returns the account's polling state after it.
func (h *fetchHarness) check(respond http.HandlerFunc) *dal.Account {
	h.t.Helper()
	h.feed.setHandler(respond)
	if !h.step(simStart.Add(365 * 24 * time.Hour)) {
		h.t.Fatal("the feed never became due")
	}
	return h.state(testHandle)
}

// assertDelay checks the wait the last check scheduled, from the moment of
// that check.
func (h *fetchHarness) assertDelay(acct *dal.Account, lo, hi time.Duration) {
	h.t.Helper()
	d := acct.NextCheckDue.Sub(h.clock.Now())
	if d < lo || d > hi {
		h.t.Errorf("next check in %s, want between %s and %s", d, lo, hi)
	}
}

func serve200(etag, lastModified string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("ETag", etag)
		w.Header().Set("Last-Modified", lastModified)
		fmt.Fprint(w, emptyRss)
	}
}

func serveStatus(code int, retryAfter string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if retryAfter != "" {
			w.Header().Set("Retry-After", retryAfter)
		}
		w.WriteHeader(code)
	}
}

const lastMod = "Wed, 21 Oct 2026 07:28:00 GMT"

func TestFetchStoresValidatorsAndSendsThemBack(t *testing.T) {
	h := newFetchHarness(t, 0)

	acct := h.check(serve200(`"v1"`, lastMod))
	if req := h.feed.lastRequest(t).header; req.Get("If-None-Match") != "" || req.Get("If-Modified-Since") != "" {
		t.Errorf("first request should be unconditional, got %v", req)
	}
	if acct.FeedEtag != `"v1"` || acct.FeedLastModified != lastMod {
		t.Errorf("validators not stored: etag %q, last-modified %q", acct.FeedEtag, acct.FeedLastModified)
	}

	acct = h.check(serveStatus(http.StatusNotModified, ""))
	req := h.feed.lastRequest(t).header
	if req.Get("If-None-Match") != `"v1"` || req.Get("If-Modified-Since") != lastMod {
		t.Errorf("validators not sent back: %v", req)
	}
	if acct.FeedEtag != `"v1"` || acct.FetchFailures != 0 {
		t.Errorf("after 304: etag %q, failures %d", acct.FeedEtag, acct.FetchFailures)
	}
	// A 304 is a normal check: the 1h interval, ±20% jitter.
	h.assertDelay(acct, 48*time.Minute, 72*time.Minute)
}

func TestFetchBacksOffAndRecovers(t *testing.T) {
	h := newFetchHarness(t, 0)
	h.check(serve200(`"v1"`, ""))

	acct := h.check(serveStatus(http.StatusInternalServerError, ""))
	if acct.FetchFailures != 1 {
		t.Errorf("failures = %d, want 1", acct.FetchFailures)
	}
	if acct.FeedEtag != `"v1"` {
		t.Errorf("a failed check must keep the validators, etag is %q", acct.FeedEtag)
	}
	h.assertDelay(acct, 96*time.Minute, 144*time.Minute) // 2 × 1h ±20%

	acct = h.check(serveStatus(http.StatusInternalServerError, ""))
	if acct.FetchFailures != 2 {
		t.Errorf("failures = %d, want 2", acct.FetchFailures)
	}
	h.assertDelay(acct, 192*time.Minute, 288*time.Minute) // 4 × 1h ±20%

	acct = h.check(serve200(`"v2"`, ""))
	if acct.FetchFailures != 0 || acct.FeedEtag != `"v2"` {
		t.Errorf("after recovery: failures %d, etag %q", acct.FetchFailures, acct.FeedEtag)
	}
}

func TestFetchHonoursRetryAfter(t *testing.T) {
	h := newFetchHarness(t, 0)
	acct := h.check(serveStatus(http.StatusTooManyRequests, "72000")) // 20h
	h.assertDelay(acct, 20*time.Hour, 20*time.Hour)
}

func TestFetchCapsAnAbsurdRetryAfter(t *testing.T) {
	h := newFetchHarness(t, 0)
	acct := h.check(serveStatus(http.StatusServiceUnavailable, "3024000")) // 5 weeks
	h.assertDelay(acct, 24*time.Hour, 24*time.Hour)
}

func TestFetchRespectsConfiguredRetryCap(t *testing.T) {
	h := newFetchHarness(t, 6)
	acct := h.check(serveStatus(http.StatusServiceUnavailable, "3024000"))
	h.assertDelay(acct, 6*time.Hour, 6*time.Hour)
}