#172: three more owner-notice conditions: no-browser-route, sidecar-down, adapter-broken

FaultsFrom judges three more faults at the shared twelve-hour OwnerWindow:
a no-browser-route Site's refusing run (browser-backed Sites excluded), a
sidecar no Lane has reached (one site-wide fault), and a Site where more
than half of Series hold an old no-chapter failure row. recordPass fills
the inputs from three new store reads (RefusingSince, SidecarOK,
NoChapterShare), each failing independently and never failing a pass, and
the clear loop forgets the empty-Site row too so a lifted sidecar-down
fires again on return.
This commit is contained in:
2026-08-23 02:44:45 +07:00
parent 3d746092ab
commit 28fef689ec
5 changed files with 853 additions and 20 deletions
+431
View File
@@ -3181,6 +3181,437 @@ func TestFaultsFromStall(t *testing.T) {
}
}
// The #172 conditions are judged from the durable inputs alone, like the
// stall. A refusal is only a fault when the Site has no browser route to
// clear it (AC2's "browser Site" exclusions), and only once it is older than
// the owner window.
func TestFaultsFromNoBrowserRoute(t *testing.T) {
now := time.UnixMilli(5_000_000_000)
old := now.Add(-2 * OwnerWindow).UnixMilli() // a two-day refusal
fresh := now.Add(-OwnerWindow / 2).UnixMilli() // inside the window
tests := []struct {
name string
in FaultInput
want int
}{
{
name: "plain-TLS Sites refusing for two days are faults",
in: FaultInput{RefusingSince: map[string]int64{"asura": old, "demonic": now.Add(-3 * OwnerWindow).UnixMilli()}},
want: 2,
},
{
name: "a browser-backed Site's refusal is a route it has, not a fault",
in: FaultInput{RefusingSince: map[string]int64{"comix": old, "kagane": old, "novelfull": old}},
want: 0,
},
{
name: "a fresh refusal is not old enough",
in: FaultInput{RefusingSince: map[string]int64{"asura": fresh}},
want: 0,
},
{
name: "no refusal is no fault",
in: FaultInput{},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
faults := FaultsFrom(tt.in, 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 != ConditionNoBrowserRoute || f.Site == "" || f.Since != tt.in.RefusingSince[f.Site] {
t.Fatalf("fault = %+v, want no-browser-route on the refusing Site since its run began", f)
}
}
})
}
}
// sidecar-down is one site-wide fault: every browser Lane's latest pass must
// be a sidecar skip (SkipSidecarDown or SkipNoFetcher — the skip clause keeps
// an asleep Lane out) and each Lane's most recent sidecar-reaching pass must
// be older than the window. The fault's Since is the last moment any Lane
// reached the sidecar.
func TestFaultsFromSidecarDown(t *testing.T) {
now := time.UnixMilli(5_000_000_000)
oldReach := now.Add(-2 * OwnerWindow).UnixMilli()
skip := func(site string) store.LanePass {
return store.LanePass{Site: site, RanAt: oldReach, Skip: SkipSidecarDown}
}
allSkipped := []store.LanePass{skip("comix"), skip("kagane"), skip("novelfull")}
reached := now.Add(-OwnerWindow / 2).UnixMilli() // inside the window
tests := []struct {
name string
in FaultInput
want int
wantSince int64
}{
{
name: "every browser Lane sidecar-skipped past the window is one fault",
in: FaultInput{Passes: allSkipped, SidecarOK: map[string]int64{"comix": oldReach}},
want: 1,
wantSince: oldReach,
},
{
name: "a browser Lane asleep for two days sends nothing",
in: FaultInput{
Passes: []store.LanePass{
skip("comix"),
{Site: "kagane", RanAt: oldReach, Skip: SkipAsleep},
skip("novelfull"),
},
},
want: 0,
},
{
name: "a Lane that reached the sidecar inside the window is not down",
in: FaultInput{
Passes: allSkipped,
SidecarOK: map[string]int64{"comix": reached, "kagane": reached, "novelfull": reached},
},
want: 0,
},
{
name: "a browser Site with no pass row is not judged down",
in: FaultInput{
Passes: []store.LanePass{skip("comix"), skip("kagane")},
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
faults := FaultsFrom(tt.in, 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 != ConditionSidecarDown || f.Site != "" || f.Since != tt.wantSince {
t.Fatalf("fault = %+v, want one site-wide sidecar-down since %d", f, tt.wantSince)
}
}
})
}
}
// adapter-broken fires strictly above half: a float share judges a
// four-Series Site and a 200-Series Site on the same scale, exactly half
// stays quiet, and a single failing Series never reaches it.
func TestFaultsFromAdapterBroken(t *testing.T) {
now := time.UnixMilli(5_000_000_000)
tests := []struct {
name string
in FaultInput
want int
}{
{
name: "three of four Series failing is a fault",
in: FaultInput{NoChapterShare: map[string]float64{"asura": 3.0 / 4.0}},
want: 1,
},
{
name: "exactly half is not a fault",
in: FaultInput{NoChapterShare: map[string]float64{"asura": 1.0 / 2.0}},
want: 0,
},
{
name: "one of two hundred is not a fault",
in: FaultInput{NoChapterShare: map[string]float64{"asura": 1.0 / 200.0}},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
faults := FaultsFrom(tt.in, 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 != ConditionAdapterBroken || f.Site != "asura" || f.Since != now.Add(-OwnerWindow).UnixMilli() {
t.Fatalf("fault = %+v, want adapter-broken on asura since the window", f)
}
}
})
}
}
// seedRefusingRun writes a refusing pass and the gate row that follows it,
// so a Site's run of refusing passes begins at start.
func seedRefusingRun(t *testing.T, s *store.Store, site string, start time.Time) {
t.Helper()
for i, row := range []store.LanePass{
{Site: site, RanAt: start.UnixMilli(), Skip: "", Refused: 2},
{Site: site, RanAt: start.Add(time.Minute).UnixMilli(), Skip: SkipRefusing},
} {
if err := s.RecordLanePass(row, -1); err != nil {
t.Fatalf("seed refusing run %d: %v", i, err)
}
}
}
// seedSidecarSkips writes one sidecar-skipped pass for every browser-backed
// Lane, all at the same time, so the pass log shows a sidecar that has been
// unreachable since then.
func seedSidecarSkips(t *testing.T, s *store.Store, at time.Time) {
t.Helper()
for _, site := range browserBackedSites() {
if err := s.RecordLanePass(store.LanePass{Site: site, RanAt: at.UnixMilli(), Skip: SkipSidecarDown}, -1); err != nil {
t.Fatalf("seed sidecar skip %s: %v", site, err)
}
}
}
// seedNoChapter writes an old no-chapter failure row for each Series id.
func seedNoChapter(t *testing.T, s *store.Store, site string, ids []string, failingSince int64) {
t.Helper()
for _, id := range ids {
if err := s.RecordSeriesFailure(site, id, "no_chapter", failingSince); err != nil {
t.Fatalf("seed no-chapter failure %s:%s: %v", site, id, err)
}
}
}
// no-browser-route fires once while the refusal holds, and again after it
// lifts and returns: the suppression row is the whole state, the gate row of
// a second refusing pass is the same episode, a healthy pass in between
// breaks the run and clears the row, and a later run that has aged past the
// window sends again (AC1, AC9).
func TestOwnerNoticeNoBrowserRouteFiresOncePerEpisode(t *testing.T) {
now := time.UnixMilli(5_000_000_000)
s, _ := newTestStore(t)
seedForCheck(t, s, "asura:r1", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
seedForCheck(t, s, "asura:r2", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
refusing := &fakeFetcher{status: 403}
notifier := &fakeNotifier{}
p := newTestPoller(t, s, refusing, now)
p.Now = func() time.Time { return now }
p.Notify = notifier
// A run that began before the window: seed two-day-old refusing rows,
// then a fresh pass that refuses again — the run is unbroken and still
// old, so the owner is told once.
seedRefusingRun(t, s, "asura", now.Add(-2*OwnerWindow))
p.runLanePass(context.Background(), "asura", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("notices after first refusing pass = %d, want 1", got)
}
if f := notifier.fault(0); f.Condition != ConditionNoBrowserRoute || f.Site != "asura" || f.Since != now.Add(-2*OwnerWindow).UnixMilli() {
t.Fatalf("fault = %+v, want no-browser-route on asura since the run began", f)
}
if sent, err := s.NoticeSent(ConditionNoBrowserRoute, "asura"); err != nil || !sent {
t.Fatalf("NoticeSent after first refusing pass = %v, %v; want true, nil", sent, err)
}
// A second refusing pass — the refusal gate now returns early — is the
// same episode: the gate row continues the run, no second message.
now = now.Add(time.Minute)
p.runLanePass(context.Background(), "asura", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("notices after gated refusing pass = %d, want 1 (one per episode)", got)
}
// A healthy pass in between breaks the run: the condition lifts and the
// suppression row is cleared.
now = now.Add(RefuseBackoff + time.Minute)
seedForCheck(t, s, "asura:r1", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
seedForCheck(t, s, "asura:r2", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
p.Fetch = &fakeFetcher{body: asuraSeriesFixture, status: 200}
p.runLanePass(context.Background(), "asura", 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(ConditionNoBrowserRoute, "asura"); err != nil || sent {
t.Fatalf("NoticeSent after healthy pass = %v, %v; want false, nil (row cleared)", sent, err)
}
// A later run that has aged past the window again is a new episode.
runStart := now.Add(2 * OwnerWindow)
seedRefusingRun(t, s, "asura", runStart)
now = runStart.Add(2 * OwnerWindow)
seedForCheck(t, s, "asura:r1", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
seedForCheck(t, s, "asura:r2", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
p.Fetch = refusing
p.runLanePass(context.Background(), "asura", false)
if got := notifier.callCount(); got != 2 {
t.Fatalf("notices after the returned refusal = %d, want 2 (a new episode)", got)
}
if f := notifier.fault(1); f.Condition != ConditionNoBrowserRoute || f.Site != "asura" || f.Since != runStart.UnixMilli() {
t.Fatalf("fault = %+v, want no-browser-route on asura since the new run began", f)
}
}
// A browser-backed Site's refusal sends nothing (AC2): it has a route, so a
// two-day refusal is a browser-side problem, not the no-browser-route fault.
func TestOwnerNoticeBrowserRefusalSendsNothing(t *testing.T) {
now := time.UnixMilli(5_000_000_000)
s, _ := newTestStore(t)
for i := 1; i <= 6; i++ {
seedForCheck(t, s, fmt.Sprintf("comix:b%d", i), fmt.Sprintf("https://comix.to/title/b%d", i), 0)
}
notifier := &fakeNotifier{}
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.Now = func() time.Time { return now }
p.BrowserFetch = &fakeFetcher{status: 403}
p.Notify = notifier
seedRefusingRun(t, s, "comix", now.Add(-2*OwnerWindow))
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 0 {
t.Fatalf("notices = %d, want 0 (a browser Site's refusal is not a no-browser-route)", got)
}
if sent, err := s.NoticeSent(ConditionNoBrowserRoute, "comix"); err != nil || sent {
t.Fatalf("NoticeSent = %v, %v; want false, nil", sent, err)
}
}
// sidecar-down fires once while no Lane reaches the sidecar, and again after
// it returns and dies again (AC4, AC9): the suppression row holds the empty
// Site, the first Lane to notice writes it and the others stay quiet, a
// healthy browser pass clears it, and a later outage is a new episode.
func TestOwnerNoticeSidecarDownFiresOncePerEpisode(t *testing.T) {
now := time.UnixMilli(5_000_000_000)
s, _ := newTestStore(t)
notifier := &fakeNotifier{}
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.Now = func() time.Time { return now }
p.Notify = notifier
// Seed the pass log so every browser Lane's latest pass is a sidecar
// skip older than the window, then a fresh pass that skips too.
seedSidecarSkips(t, s, now.Add(-2*OwnerWindow))
p.setBrowserDown(now)
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("notices after first sidecar-skipped pass = %d, want 1", got)
}
if f := notifier.fault(0); f.Condition != ConditionSidecarDown || f.Site != "" {
t.Fatalf("fault = %+v, want one site-wide sidecar-down", f)
}
if sent, err := s.NoticeSent(ConditionSidecarDown, ""); err != nil || !sent {
t.Fatalf("NoticeSent after first pass = %v, %v; want true, nil", sent, err)
}
// Another Lane's sidecar-skipped pass is the same episode: still one
// message.
p.runLanePass(context.Background(), "kagane", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("notices after another Lane's skipped pass = %d, want 1 (one per episode)", got)
}
// A healthy browser pass reaches the sidecar: the condition lifts and the
// suppression row is cleared. Five due Series wake the browser, and the
// series ids carry the fixture's id prefix so the scoped reader finds its
// chapters.
now = now.Add(RefuseBackoff + time.Minute)
for i := 1; i <= 5; i++ {
seedForCheck(t, s, fmt.Sprintf("comix:h%d", i), "https://comix.to/title/n8we-dungeons-and-crayons", 0)
}
p.BrowserFetch = &fakeFetcher{body: comixSeriesFixture, status: 200}
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(ConditionSidecarDown, ""); err != nil || sent {
t.Fatalf("NoticeSent after healthy pass = %v, %v; want false, nil (row cleared)", sent, err)
}
// The sidecar dies again: a new episode, told again.
now = now.Add(2 * OwnerWindow)
p.setBrowserDown(now)
seedSidecarSkips(t, s, now.Add(-time.Minute))
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 2 {
t.Fatalf("notices after the returned outage = %d, want 2 (a new episode)", got)
}
}
// A browser Lane asleep under both wake thresholds sends nothing: it never
// reached the sidecar, but its skip is not a sidecar skip, so it cannot age
// into a false alarm (AC9).
func TestOwnerNoticeSidecarDownAsleepSendsNothing(t *testing.T) {
now := time.UnixMilli(5_000_000_000)
s, _ := newTestStore(t)
notifier := &fakeNotifier{}
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.Now = func() time.Time { return now }
p.Notify = notifier
seedSidecarSkips(t, s, now.Add(-2*OwnerWindow))
if err := s.RecordLanePass(store.LanePass{Site: "kagane", RanAt: now.Add(-2*OwnerWindow + time.Minute).UnixMilli(), Skip: SkipAsleep}, -1); err != nil {
t.Fatalf("seed asleep pass: %v", err)
}
p.setBrowserDown(now)
p.runLanePass(context.Background(), "comix", false)
if got := notifier.callCount(); got != 0 {
t.Fatalf("notices = %d, want 0 (an asleep Lane is not a down sidecar)", got)
}
if sent, err := s.NoticeSent(ConditionSidecarDown, ""); err != nil || sent {
t.Fatalf("NoticeSent = %v, %v; want false, nil", sent, err)
}
}
// adapter-broken fires once while more than half of a Site's Series hold an
// old no-chapter failure row, and again after the failures clear and return:
// the suppression row is the whole state, a pass that clears the failures
// forgets the episode, and a later return sends again. The share is judged
// from durable rows, so the pass itself may be healthy (AC6, AC9).
func TestOwnerNoticeAdapterBrokenFiresOncePerEpisode(t *testing.T) {
now := time.UnixMilli(5_000_000_000)
s, _ := newTestStore(t)
for i := 1; i <= 4; i++ {
seedForCheck(t, s, fmt.Sprintf("asura:a%d", i), fmt.Sprintf("https://asurascans.com/comics/a%d", i), now.UnixMilli())
}
notifier := &fakeNotifier{}
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.Now = func() time.Time { return now }
p.Notify = notifier
// Three of four Series hold an old no-chapter row: the first pass fires
// one adapter-broken fault.
seedNoChapter(t, s, "asura", []string{"a1", "a2", "a3"}, now.Add(-2*OwnerWindow).UnixMilli())
p.runLanePass(context.Background(), "asura", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("notices after first pass = %d, want 1", got)
}
if f := notifier.fault(0); f.Condition != ConditionAdapterBroken || f.Site != "asura" || f.Since != now.Add(-OwnerWindow).UnixMilli() {
t.Fatalf("fault = %+v, want adapter-broken on asura since the window", f)
}
if sent, err := s.NoticeSent(ConditionAdapterBroken, "asura"); err != nil || !sent {
t.Fatalf("NoticeSent after first pass = %v, %v; want true, nil", sent, err)
}
// A second pass is the same episode: still one message.
p.runLanePass(context.Background(), "asura", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("notices after second pass = %d, want 1 (one per episode)", got)
}
// The failures clear: the condition lifts and the suppression row is
// cleared.
for _, id := range []string{"a1", "a2", "a3"} {
if err := s.ClearSeriesFailure("asura", id); err != nil {
t.Fatalf("clear failure %s: %v", id, err)
}
}
now = now.Add(RefuseBackoff + time.Minute)
p.runLanePass(context.Background(), "asura", false)
if got := notifier.callCount(); got != 1 {
t.Fatalf("notices after cleared failures = %d, want 1 (a healthy share sends nothing)", got)
}
if sent, err := s.NoticeSent(ConditionAdapterBroken, "asura"); err != nil || sent {
t.Fatalf("NoticeSent after cleared failures = %v, %v; want false, nil (row cleared)", sent, err)
}
// The failures return: a new episode, told again.
seedNoChapter(t, s, "asura", []string{"a1", "a2", "a3"}, now.Add(-2*OwnerWindow).UnixMilli())
now = now.Add(time.Minute)
p.runLanePass(context.Background(), "asura", false)
if got := notifier.callCount(); got != 2 {
t.Fatalf("notices after the returned failures = %d, want 2 (a new episode)", got)
}
}
// 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