Owner notices: the stall, one webhook path from env to Discord (#171)
Rollout note: the owner_notices table starts empty, so the first pass after deploy sends for conditions already true — correct per one-row-per-episode; say so rather than have it reported as a bug. Prod step: create the webhook, set DISCORD_WEBHOOK_URL on the deployment, redeploy — unset is silent by design, and without that step the feature ships dark. Security invariants preserved: the webhook address is a secret in the class of TOKEN_KEY (never logged, never on a config-printing line), and the owner gate is unchanged.
This commit is contained in:
@@ -159,6 +159,35 @@ func (f *fakeBytesCoverFetcher) callCount() int {
|
||||
return len(f.calls)
|
||||
}
|
||||
|
||||
// fakeNotifier records the owner notices the poller sends, standing in for
|
||||
// the Discord webhook the way fakeFetcher stands in for the network. An err
|
||||
// set after construction makes every subsequent send fail, for the retry
|
||||
// test.
|
||||
type fakeNotifier struct {
|
||||
mu sync.Mutex
|
||||
calls []Fault
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) Notify(_ context.Context, fault Fault, _, _ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, fault)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.calls)
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) fault(i int) Fault {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls[i]
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -3092,3 +3121,214 @@ func TestRunOnceSiteCompletedWritesDespiteUnchangedChapter(t *testing.T) {
|
||||
t.Fatalf("site_completed_at after unchanged-chapter completed poll = %d, want %d", got, at.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
// The stall judgement (issue #171) selects exactly the row the mid-loop
|
||||
// browser loss writes — due > 0, none checked, no skip value, and no refusal.
|
||||
// The no-refusal clause is AC6's amendment: the twice-refused break happens
|
||||
// inside the pass loop, not on an early return, so a newly-gated Site would
|
||||
// otherwise send two messages. A pause is excluded by Skip == "" (SkipPaused):
|
||||
// the owner's own act is not reported back (AC10).
|
||||
func TestFaultsFromStall(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000)
|
||||
tests := []struct {
|
||||
name string
|
||||
pass store.LanePass
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "stall-shaped pass is a stall",
|
||||
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 2, Checked: 0, Skip: "", Refused: 0},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "nothing due is not a stall",
|
||||
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 0, Checked: 0, Skip: "", Refused: 0},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "a pass that checked is not a stall",
|
||||
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 1, Checked: 1, Skip: "", Refused: 0},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "paused is the owner's own act, not a stall",
|
||||
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 1, Checked: 0, Skip: SkipPaused, Refused: 0},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "refusing has a skip value, not a stall",
|
||||
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 1, Checked: 0, Skip: SkipRefusing, Refused: 0},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "stalled and refused fires nothing (AC6's collision)",
|
||||
pass: store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 2, Checked: 0, Skip: "", Refused: 1},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
faults := FaultsFrom(FaultInput{Passes: []store.LanePass{tt.pass}}, now)
|
||||
if got := len(faults); got != tt.want {
|
||||
t.Fatalf("FaultsFrom = %+v, want %d fault(s)", faults, tt.want)
|
||||
}
|
||||
for _, f := range faults {
|
||||
if f.Condition != ConditionStall || f.Site != "comix" || f.Since != now.UnixMilli() {
|
||||
t.Fatalf("fault = %+v, want stall on comix since the pass time", f)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The stall fires exactly once while it holds, and again after it lifts and
|
||||
// returns: the suppression row is the whole state, so the second stall-shaped
|
||||
// pass is the same episode, a healthy pass in between clears the row, and the
|
||||
// next stall is a new episode that sends again.
|
||||
func TestOwnerNoticeStallFiresOncePerEpisode(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000)
|
||||
s, _ := newTestStore(t)
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
|
||||
notifier := &fakeNotifier{}
|
||||
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||
p.Now = func() time.Time { return now }
|
||||
p.BrowserFetch = &fakeFetcher{status: 200, err: interrupted}
|
||||
p.Notify = notifier
|
||||
|
||||
// First stall-shaped pass: the episode begins, the owner is told once.
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after first stall = %d, want 1", got)
|
||||
}
|
||||
if f := notifier.fault(0); f.Condition != ConditionStall || f.Site != "comix" || f.Since != now.UnixMilli() {
|
||||
t.Fatalf("fault = %+v, want stall on comix since the pass time", f)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || !sent {
|
||||
t.Fatalf("NoticeSent after first stall = %v, %v; want true, nil", sent, err)
|
||||
}
|
||||
|
||||
// A second stall-shaped pass — the series was stamped, so re-seed it — is
|
||||
// the same episode: no second message.
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
now = now.Add(RefuseBackoff + time.Minute)
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after second stall = %d, want 1 (one per episode)", got)
|
||||
}
|
||||
|
||||
// A healthy pass in between lifts the stall: the episode ends and the
|
||||
// suppression row is cleared.
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
p.BrowserFetch = &fakeFetcher{body: comixSeriesFixture, status: 200}
|
||||
now = now.Add(RefuseBackoff + time.Minute)
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("notices after healthy pass = %d, want 1 (a healthy pass sends nothing)", got)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent after healthy pass = %v, %v; want false, nil (row cleared)", sent, err)
|
||||
}
|
||||
|
||||
// The next stall is a new episode: the owner is told again.
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
p.BrowserFetch = &fakeFetcher{status: 200, err: interrupted}
|
||||
now = now.Add(RefuseBackoff + time.Minute)
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 2 {
|
||||
t.Fatalf("notices after the next stall = %d, want 2 (again after it lifts and returns)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A paused Lane sends nothing: the pause is the owner's own act, and the
|
||||
// skip value already keeps it out of the stall test (AC10).
|
||||
func TestOwnerNoticePausedSendsNothing(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000)
|
||||
s, _ := newTestStore(t)
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
if err := s.PauseLane("comix", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("PauseLane: %v", err)
|
||||
}
|
||||
notifier := &fakeNotifier{}
|
||||
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||
p.BrowserFetch = &fakeFetcher{status: 200}
|
||||
p.Notify = notifier
|
||||
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 0 {
|
||||
t.Fatalf("notices while paused = %d, want 0", got)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent while paused = %v, %v; want false, nil", sent, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed POST is logged and the notified stamp left unset, so the next pass
|
||||
// retries while the condition holds. No queue, no backoff — the condition is
|
||||
// durable, and the suppression row is the only state.
|
||||
func TestOwnerNoticeFailedSendRetriesNextPass(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000)
|
||||
s, _ := newTestStore(t)
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
|
||||
notifier := &fakeNotifier{err: errors.New("webhook down")}
|
||||
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||
p.Now = func() time.Time { return now }
|
||||
p.BrowserFetch = &fakeFetcher{status: 200, err: interrupted}
|
||||
p.Notify = notifier
|
||||
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 1 {
|
||||
t.Fatalf("send attempts = %d, want 1", got)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent after failed send = %v, %v; want false, nil (stamp left unset)", sent, err)
|
||||
}
|
||||
|
||||
// The webhook recovers: the next stall-shaped pass delivers the one
|
||||
// message the episode was owed.
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
notifier.err = nil
|
||||
now = now.Add(RefuseBackoff + time.Minute)
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if got := notifier.callCount(); got != 2 {
|
||||
t.Fatalf("send attempts after recovery = %d, want 2 (retried next pass)", got)
|
||||
}
|
||||
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || !sent {
|
||||
t.Fatalf("NoticeSent after recovery = %v, %v; want true, nil", sent, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Nil notifier means the whole path is off: a stall-shaped pass panics
|
||||
// nothing and stamps no row, yet the clear still runs, so a deployment that
|
||||
// turns the webhook off does not leave stale rows that suppress the first
|
||||
// real notice after it is turned back on.
|
||||
func TestOwnerNoticeNilNotifierStillClears(t *testing.T) {
|
||||
now := time.UnixMilli(5_000_000)
|
||||
s, _ := newTestStore(t)
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
|
||||
|
||||
// A stall-shaped pass with no notifier configured: nothing panics and no
|
||||
// row is stamped — a missing webhook is a silent, complete off switch.
|
||||
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||
p.Now = func() time.Time { return now }
|
||||
p.BrowserFetch = &fakeFetcher{status: 200, err: interrupted}
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent with nil notifier = %v, %v; want false, nil", sent, err)
|
||||
}
|
||||
|
||||
// A stale row from before the webhook was turned off must not survive a
|
||||
// healthy pass: the clear runs even though no notifier is configured.
|
||||
if err := s.MarkNoticeSent(ConditionStall, "comix", now.UnixMilli()); err != nil {
|
||||
t.Fatalf("seed notice row: %v", err)
|
||||
}
|
||||
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||
now = now.Add(RefuseBackoff + time.Minute)
|
||||
p.BrowserFetch = &fakeFetcher{body: comixSeriesFixture, status: 200}
|
||||
p.runLanePass(context.Background(), "comix", false)
|
||||
if sent, err := s.NoticeSent(ConditionStall, "comix"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent after healthy pass with nil notifier = %v, %v; want false, nil (cleared)", sent, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user