Ctrl K
ComunidadGuías…

internal/flow/flow_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-FileCopyrightText: 2026 Rashik Adhikari
// SPDX-License-Identifier: Apache-2.0

package flow

import (
	"strings"
	"testing"
	"time"
)

var t0 = time.Date(2026, 9, 26, 9, 0, 0, 0, time.UTC)

func aFlow() Flow {
	return Flow{
		Name: "incidents", What: "page the on-call for new criticals",
		Every: time.Hour,
		Steps: []Step{
			{Name: "only criticals", Kind: Keep,
				Rejects: &Exit{Kind: Drop, Why: "not worth a page at night"}},
			{Name: "add the runbook", Kind: Change},
			{Name: "page the on-call", Kind: Act, Reaches: "pagerduty",
				Retries: 3},
		},
	}
}

// TestAFilterMustSayWhereItsRejectsGo is the Zapier failure, refused at
// definition time rather than discovered in a log.
func TestAFilterMustSayWhereItsRejectsGo(t *testing.T) {
	f := aFlow()
	f.Steps[0].Rejects = nil
	err := f.Validate()
	if err == nil {
		t.Fatal("a filter with nowhere for its rejects to go was accepted")
	}
	if !strings.Contains(err.Error(), "only criticals") {
		t.Fatalf("the refusal does not name the filter: %v", err)
	}

	// Dropping is allowed, and has to be explained.
	f.Steps[0].Rejects = &Exit{Kind: Drop}
	if err := f.Validate(); err == nil {
		t.Fatal("an unexplained drop was accepted")
	}
	f.Steps[0].Rejects = &Exit{Kind: Drop, Why: "duplicates"}
	if err := f.Validate(); err != nil {
		t.Fatalf("an explained drop was refused: %v", err)
	}
	// Diverting has to name a destination.
	f.Steps[0].Rejects = &Exit{Kind: Divert}
	if err := f.Validate(); err == nil {
		t.Fatal("a divert to nowhere was accepted")
	}
}

func TestAFlowMustSayHowOftenItRuns(t *testing.T) {
	f := aFlow()
	f.Every = 0
	err := f.Validate()
	if err == nil {
		t.Fatal("a flow with no expected cadence was accepted")
	}
	if !strings.Contains(err.Error(), "stopped") {
		t.Fatalf("the refusal does not say why it matters: %v", err)
	}
}

func TestAFlowThatNeverActsSaysSo(t *testing.T) {
	f := aFlow()
	f.Steps = f.Steps[:2]
	if err := f.Validate(); err == nil {
		t.Fatal("a flow that only filters was accepted as an automation")
	}
}

func TestStepValidation(t *testing.T) {
	for _, c := range []struct {
		name string
		s    Step
	}{
		{"no name", Step{Kind: Act, Reaches: "x"}},
		{"no kind", Step{Name: "a"}},
		{"act with no reach", Step{Name: "a", Kind: Act}},
		{"rejects on a change", Step{Name: "a", Kind: Change,
			Rejects: &Exit{Kind: Hold}}},
		{"retries on a filter", Step{Name: "a", Kind: Keep, Retries: 2,
			Rejects: &Exit{Kind: Hold}}},
		{"too many retries", Step{Name: "a", Kind: Act, Reaches: "x",
			Retries: MaxRetries + 1}},
	} {
		if err := c.s.Validate(); err == nil {
			t.Errorf("%s: accepted", c.name)
		}
	}
	dup := aFlow()
	dup.Steps[1].Name = "only criticals"
	if err := dup.Validate(); err == nil {
		t.Fatal("two steps with one name were accepted")
	}
}

// TestARunThatLosesThingsIsTheFailure.
func TestARunThatLosesThingsIsTheFailure(t *testing.T) {
	f := aFlow()
	r := Run{Flow: f.Name, Started: t0, In: 100, Acted: 3}
	if err := r.Reject(f.Steps[0], 40); err != nil {
		t.Fatal(err)
	}
	if r.Balances() {
		t.Fatal("43 of 100 balanced")
	}
	if r.Unaccounted() != 57 {
		t.Fatalf("unaccounted = %d", r.Unaccounted())
	}
	err := r.Check()
	if err == nil {
		t.Fatal("a run that lost 57 items passed")
	}
	if !strings.Contains(err.Error(), "57") ||
		!strings.Contains(err.Error(), "quietly") {
		t.Fatalf("the refusal is not specific: %v", err)
	}

	// Account for the rest and it passes.
	if err := r.Reject(f.Steps[0], 55); err != nil {
		t.Fatal(err)
	}
	r.Fail("page the on-call", "inc-9", "rate limited", 3)
	r.Held = 1
	if err := r.Check(); err != nil {
		t.Fatalf("a balanced run was refused: %v", err)
	}
	if r.Out() != 100 {
		t.Fatalf("out = %d", r.Out())
	}
}

// TestCountingSomethingTwiceIsAlsoABug.
func TestCountingSomethingTwiceIsAlsoABug(t *testing.T) {
	r := Run{Flow: "x", In: 5, Acted: 9}
	err := r.Check()
	if err == nil {
		t.Fatal("a run that did more than it received passed")
	}
	if !strings.Contains(err.Error(), "twice") {
		t.Fatalf("the refusal does not name the bug: %v", err)
	}
}

// TestQuietIsNotTheSameAsDiscarding — conflating them is how a filter that
// started matching everything goes unnoticed for a month.
func TestQuietIsNotTheSameAsDiscarding(t *testing.T) {
	quiet := Run{Flow: "x", In: 0}
	if !quiet.Quiet() || quiet.Why() != "nothing arrived" {
		t.Fatalf("quiet run: %q", quiet.Why())
	}
	f := aFlow()
	busy := Run{Flow: "x", In: 100}
	if err := busy.Reject(f.Steps[0], 100); err != nil {
		t.Fatal(err)
	}
	if busy.Quiet() {
		t.Fatal("a run that discarded a hundred items reported as quiet")
	}
	why := busy.Why()
	if !strings.Contains(why, "none were acted on") ||
		!strings.Contains(why, "only criticals dropped 100") {
		t.Fatalf("why = %q", why)
	}
	if !strings.Contains(why, "not worth a page") {
		t.Fatalf("the stated reason for the drop is missing: %q", why)
	}
}

// TestGapsFindWhatPollingMissed.
func TestGapsFindWhatPollingMissed(t *testing.T) {
	hour := func(n int) time.Time {
		return t0.Add(time.Duration(n) * time.Hour)
	}
	runs := []Run{
		{Flow: "x", Started: hour(1), From: hour(0), To: hour(1)},
		{Flow: "x", Started: hour(2), From: hour(1), To: hour(2)},
		// The source was busy and this run started late, from where it
		// happened to be rather than where the last one stopped.
		{Flow: "x", Started: hour(5), From: hour(4), To: hour(5)},
		{Flow: "x", Started: hour(6), From: hour(5), To: hour(6)},
	}
	g := Gaps(runs)
	if len(g) != 1 {
		t.Fatalf("%d gap(s)", len(g))
	}
	if g[0].For != 2*time.Hour {
		t.Fatalf("gap = %s", g[0].For)
	}
	if !g[0].From.Equal(hour(2)) || !g[0].To.Equal(hour(4)) {
		t.Fatalf("gap is %s to %s", g[0].From, g[0].To)
	}
	// Overlapping windows lose nothing.
	overlap := []Run{
		{From: hour(0), To: hour(2)},
		{From: hour(1), To: hour(3)},
	}
	if len(Gaps(overlap)) != 0 {
		t.Fatal("overlapping windows reported a gap")
	}
	// Runs with no window are not evidence either way.
	if len(Gaps([]Run{{Started: hour(1)}, {Started: hour(9)}})) != 0 {
		t.Fatal("runs with no window produced a gap")
	}
}

// TestSilenceIsAFailure.
func TestSilenceIsAFailure(t *testing.T) {
	f := aFlow() // every hour
	if over, _ := f.Overdue(t0, t0.Add(time.Hour)); over {
		t.Fatal("a flow exactly on time was overdue")
	}
	if over, _ := f.Overdue(t0, t0.Add(80*time.Minute)); over {
		t.Fatal("a flow inside its grace was overdue")
	}
	over, by := f.Overdue(t0, t0.Add(4*time.Hour))
	if !over || by != 3*time.Hour {
		t.Fatalf("overdue = %v by %s", over, by)
	}
	if over, _ := f.Overdue(time.Time{}, t0); !over {
		t.Fatal("a flow that has never run is not overdue")
	}
}

// TestTroubleIsRankedNotThresholded.
func TestTroubleIsRankedNotThresholded(t *testing.T) {
	f := aFlow()
	hour := func(n int) time.Time {
		return t0.Add(time.Duration(n) * time.Hour)
	}
	lost := Run{Flow: f.Name, Started: hour(1), From: hour(0), To: hour(1),
		In: 10, Acted: 2}
	broke := Run{Flow: f.Name, Started: hour(4), From: hour(3), To: hour(4),
		In: 2, Acted: 0}
	broke.Fail("page the on-call", "inc-1", "rate limited", 3)
	broke.Fail("page the on-call", "inc-2", "rate limited", 3)
	broke.Held = 0
	// broke: 2 in, 0 acted, 2 failed — balances.

	got := f.Look([]Run{lost, broke}, hour(5))
	if len(got) < 3 {
		t.Fatalf("%d trouble(s): %+v", len(got), got)
	}
	if got[0].Weight != Lost {
		t.Fatalf("the worst is %s at %d, want lost", got[0].What,
			got[0].Weight)
	}
	var sawGap, sawBroke bool
	for _, tr := range got {
		if tr.Weight == Missed {
			sawGap = true
		}
		if tr.Weight == Broke {
			sawBroke = true
			if !strings.Contains(tr.Detail, "rate limited") ||
				!strings.Contains(tr.Detail, "page the on-call") {
				t.Fatalf("a failure without a diagnosis: %q", tr.Detail)
			}
		}
	}
	if !sawGap {
		t.Fatal("the two-hour gap between the runs was not reported")
	}
	if !sawBroke {
		t.Fatal("the failures were not reported")
	}
	for i := 1; i < len(got); i++ {
		if got[i].Weight > got[i-1].Weight {
			t.Fatal("trouble is not ranked")
		}
	}
}

// TestAFlowRunningAndDiscardingEverything is the failure that looks most
// like success.
func TestAFlowRunningAndDiscardingEverything(t *testing.T) {
	f := aFlow()
	var runs []Run
	for i := range 3 {
		r := Run{Flow: f.Name, Started: t0.Add(time.Duration(i) * time.Hour),
			In: 20}
		if err := r.Reject(f.Steps[0], 20); err != nil {
			t.Fatal(err)
		}
		if err := r.Check(); err != nil {
			t.Fatal(err)
		}
		runs = append(runs, r)
	}
	got := f.Look(runs, t0.Add(2*time.Hour+30*time.Minute))
	found := false
	for _, tr := range got {
		if tr.Weight == Discarding {
			found = true
			if !strings.Contains(tr.What, "3 run(s)") ||
				!strings.Contains(tr.What, "60 item(s)") {
				t.Fatalf("what = %q", tr.What)
			}
		}
	}
	if !found {
		t.Fatal("a flow that ran three times and did nothing was reported " +
			"as healthy")
	}

	// A busy hour in the middle breaks the streak, and one barren run on
	// its own is not evidence of anything.
	runs[1].Acted = 20
	for _, tr := range f.Look(runs, t0.Add(150*time.Minute)) {
		if tr.Weight == Discarding {
			t.Fatalf("a single barren run was reported: %q", tr.What)
		}
	}
	// A quiet run does not break a streak. Nothing arrived, so nothing
	// happening is the correct outcome and is not evidence that whatever
	// was discarding things has recovered.
	runs[1] = Run{Flow: f.Name, Started: runs[1].Started, In: 0}
	if n, items := barren(runs); n != 2 || items != 40 {
		t.Fatalf("a quiet run in the middle gave a streak of %d over %d "+
			"item(s); it should join the two barren runs either side",
			n, items)
	}
}

func TestDropsAndReachesAreListable(t *testing.T) {
	f := aFlow()
	if err := f.Validate(); err != nil {
		t.Fatal(err)
	}
	d := f.Drops()
	if len(d) != 1 || d[0].Name != "only criticals" {
		t.Fatalf("drops = %+v", d)
	}
	r := f.Reaches()
	if len(r) != 1 || r[0] != "pagerduty" {
		t.Fatalf("reaches = %v", r)
	}
}