package main import ( "context" "strings" "testing" "time" "github.com/charmbracelet/lipgloss" "git.tornberg.me/go-otel/internal/alerts" "git.tornberg.me/go-otel/internal/features" ) // fakeClock builds a scanner with a canned loader and no ClickHouse. func fakeScanner(events []features.Event, dataSince time.Time) *scanner { return &scanner{ load: func(context.Context, time.Time, time.Time) ([]features.Event, error) { return events, nil }, dataSince: func(context.Context) time.Time { return dataSince }, close: func() {}, cfg: features.SpikeConfig{Window: time.Minute, BaselineWindows: 2, Multiple: 3, MinExcess: 5}, pager: features.NewSuppressor(30 * time.Minute), active: map[string]time.Time{}, } } func errs(n int, at time.Time) []features.Event { events := make([]features.Event, 0, n) for i := 0; i < n; i++ { events = append(events, features.Event{ Timestamp: at, Service: "checkout", Kind: "span", IsError: true, StatusCode: 500, Route: "/api/payments", SpanName: "POST /api/payments", Message: "payment gateway timeout", PatternSignature: "checkout|500|/api/payments", IssueFingerprint: "issue-checkout-500", }) } return events } // TestScannerPagesOnceThenOngoing mirrors the notification lifecycle: the // first window past the threshold pages, later windows are ongoing until the // count doubles (escalation) or the spike disappears. func TestScannerPagesOnceThenOngoing(t *testing.T) { base := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) sc := fakeScanner(nil, base.Add(-time.Hour)) // The loader places the events inside whatever window is being // evaluated, so every scan sees a live spike in its current window. count := 12 sc.load = func(_ context.Context, _, end time.Time) ([]features.Event, error) { return errs(count, end.Add(-30*time.Second)), nil } first := sc.Scan(context.Background(), base) if len(first.Issues) != 1 || !first.Issues[0].paged { t.Fatalf("first scan should page one issue, got %+v", first.Issues) } if first.Issues[0].alert.Priority() != alerts.PriorityHigh { t.Fatalf("500 on a span should be high priority, got %s", first.Issues[0].alert.Priority()) } if got := first.Issues[0].firstSeen; !got.Equal(first.WindowEnd) { t.Fatalf("firstSeen should be the first window end, got %v", got) } // A little more traffic, still not double: ongoing, not paged again. count = 14 second := sc.Scan(context.Background(), base.Add(time.Minute)) if len(second.Issues) != 1 || second.Issues[0].paged { t.Fatalf("second scan should be ongoing, got %+v", second.Issues) } if got := second.Issues[0].firstSeen; !got.Equal(first.WindowEnd) { t.Fatalf("ongoing issue should keep its first-seen time, got %v want %v", got, first.WindowEnd) } // Escalation: at least double the count pages again. count = 30 third := sc.Scan(context.Background(), base.Add(2*time.Minute)) if len(third.Issues) != 1 || !third.Issues[0].paged { t.Fatalf("doubled count should page again, got %+v", third.Issues) } // Recovery: the spike disappears and is reported once. sc.load = func(_ context.Context, _, _ time.Time) ([]features.Event, error) { return nil, nil } fourth := sc.Scan(context.Background(), base.Add(3*time.Minute)) if len(fourth.Issues) != 0 { t.Fatalf("no events should mean no issues, got %+v", fourth.Issues) } if len(fourth.Recovered) != 1 || fourth.Recovered[0].Service != "checkout" { t.Fatalf("expected checkout in recovered, got %+v", fourth.Recovered) } again := sc.Scan(context.Background(), base.Add(4*time.Minute)) if len(again.Recovered) != 0 { t.Fatalf("recovery should be reported once, got %+v", again.Recovered) } } func TestScannerAllClearAndError(t *testing.T) { sc := fakeScanner(nil, time.Time{}) empty := sc.Scan(context.Background(), time.Now()) if len(empty.Issues) != 0 || empty.Err != nil { t.Fatalf("empty loader should yield a clean snapshot, got %+v", empty) } sc.load = func(context.Context, time.Time, time.Time) ([]features.Event, error) { return nil, context.DeadlineExceeded } failed := sc.Scan(context.Background(), time.Now()) if failed.Err == nil { t.Fatal("loader error should surface in the snapshot") } } func TestGridLayoutFillsWideShortDisplays(t *testing.T) { // 300 columns, one card row of height: six issues, one row of wide cards. perRow, rows, shown, cardW := gridLayout(6, 300, 8) if perRow != 6 || rows != 1 || shown != 6 { t.Fatalf("want all 6 on one row, got perRow=%d rows=%d shown=%d", perRow, rows, shown) } if cardW < cardMinWidth || cardW > cardMaxWidth { t.Fatalf("card width %d out of range", cardW) } // More issues than fit: cap at one row and let the footer count the rest. perRow, rows, shown, _ = gridLayout(20, 300, 8) if rows != 1 || shown != perRow { t.Fatalf("want a single row capped at width, got rows=%d shown=%d perRow=%d", rows, shown, perRow) } // A taller display wraps to a second row. _, rows, shown, _ = gridLayout(20, 300, 15) if rows != 2 { t.Fatalf("15 rows of height should fit two card rows, got %d", rows) } if shown != perRow*2 { t.Fatalf("shown %d should fill two rows of %d", shown, perRow) } // Narrow display: at least one card still renders. perRow, rows, shown, cardW = gridLayout(3, 36, 8) if perRow < 1 || rows != 1 || shown != 1 || cardW < cardMinWidth { t.Fatalf("narrow display should show one card, got perRow=%d rows=%d shown=%d cardW=%d", perRow, rows, shown, cardW) } } func cardAt(t *testing.T, n int) (snapshot, snapshotOptions) { t.Helper() now := time.Date(2025, 6, 1, 12, 45, 0, 0, time.UTC) sp := features.Spike{ Service: "checkout", WindowStart: now.Add(-5 * time.Minute), WindowEnd: now, ErrorCount: 38, BaselineMean: 0.3, Excess: 37.7, Patterns: []features.PatternDelta{{ Signature: "checkout|500|/api/payments", Count: 38, Excess: 37.7, Novel: true, Clients: 12, Services: []string{"checkout", "payments-db"}, Example: features.Event{ Service: "checkout", Kind: "span", StatusCode: 500, Route: "/api/payments", SpanName: "POST /api/payments", Message: "payment gateway timeout", IssueFingerprint: "abcdef1234567890", }, }}, } snap := snapshot{ScannedAt: now, WindowStart: sp.WindowStart, WindowEnd: now, Events: 8213, Issues: make([]issueView, n)} for i := range snap.Issues { snap.Issues[i] = issueView{alert: alerts.NewAlert(sp, now), key: sp.Service, paged: i == 0, firstSeen: now.Add(-12 * time.Minute)} } return snap, snapshotOptions{Width: 200, Height: 12, Title: "otel issues", BaseURL: "https://otel.k6n.net", NextScanAt: now.Add(30 * time.Second), Now: now} } func TestRenderSnapshotShowsCards(t *testing.T) { snap, o := cardAt(t, 8) out := renderSnapshot(snap, o) if !strings.Contains(out, "PAGED") || !strings.Contains(out, "checkout") { t.Fatalf("dashboard should show the paged checkout card:\n%s", out) } if !strings.Contains(out, "HTTP 500") { t.Fatalf("card should describe the failure:\n%s", out) } if lines := strings.Split(out, "\n"); len(lines) > 12 { t.Fatalf("dashboard must respect the height budget, got %d lines", len(lines)) } // The footer counts what the single card row could not show: five cards // fit in 200 columns, three are hidden. if !strings.Contains(out, "+3 more") { t.Fatalf("footer should count hidden cards:\n%s", out) } } func TestRenderSnapshotAllClear(t *testing.T) { now := time.Date(2025, 6, 1, 12, 45, 0, 0, time.UTC) snap := snapshot{ScannedAt: now, WindowStart: now.Add(-5 * time.Minute), WindowEnd: now, Events: 8213} out := renderSnapshot(snap, snapshotOptions{Width: 200, Height: 12, Title: "otel issues", Now: now}) if !strings.Contains(out, "ALL CLEAR") { t.Fatalf("empty scan should show all clear:\n%s", out) } } func TestRenderSnapshotScanError(t *testing.T) { snap := snapshot{Err: context.DeadlineExceeded} out := renderSnapshot(snap, snapshotOptions{Width: 200, Height: 12, Title: "otel issues", Now: time.Now()}) if !strings.Contains(out, "SCAN FAILED") { t.Fatalf("failed scan should be visible:\n%s", out) } } func TestSortIssuesPagedFirst(t *testing.T) { now := time.Now() mk := func(service string, paged bool, prio alerts.Priority, excess float64) issueView { pat := features.PatternDelta{Example: features.Event{Service: service, StatusCode: 500}} if prio == alerts.PriorityLow { pat.Example.StatusCode = 404 } return issueView{paged: paged, alert: alerts.NewAlert(features.Spike{ Service: service, Excess: excess, Patterns: []features.PatternDelta{pat}, }, now)} } issues := []issueView{ mk("quiet", false, alerts.PriorityHigh, 40), mk("loud", true, alerts.PriorityLow, 10), mk("burning", false, alerts.PriorityHigh, 99), } sortIssues(issues) if issues[0].alert.Spike.Service != "loud" { t.Fatalf("paged issue should come first, got %s", issues[0].alert.Spike.Service) } if issues[1].alert.Spike.Service != "burning" || issues[2].alert.Spike.Service != "quiet" { t.Fatalf("then by priority and excess, got %s, %s", issues[1].alert.Spike.Service, issues[2].alert.Spike.Service) } } func TestBarAndMinimal(t *testing.T) { out := bar(40, "left", "right") if lipgloss.Width(out) != 40 { t.Fatalf("bar should pad to width, got %q (%d)", out, lipgloss.Width(out)) } if out2 := bar(8, "left", "right"); lipgloss.Width(out2) > 8 { t.Fatalf("bar should truncate when both sides do not fit, got %q", out2) } snap := snapshot{Err: context.DeadlineExceeded} if got := renderMinimal(snap, snapshotOptions{}); !strings.Contains(got, "SCAN FAILED") { t.Fatalf("minimal render should surface scan errors, got %q", got) } } func TestModelKeepsLastGoodSnapshotOnScanError(t *testing.T) { m := newModel(nil, modelConfig{Interval: time.Minute}) good := snapshot{ScannedAt: time.Now(), Issues: []issueView{{}}} updated, _ := m.Update(snapshotMsg(good)) m = updated.(model) failed := snapshot{Err: context.DeadlineExceeded} updated, _ = m.Update(snapshotMsg(failed)) m = updated.(model) if len(m.snap.Issues) != 1 { t.Fatalf("failed scan should not erase the last good snapshot: %+v", m.snap) } if m.scanErr == nil { t.Fatal("failed scan should be visible separately from the last good snapshot") } updated, _ = m.Update(snapshotMsg(snapshot{ScannedAt: time.Now()})) m = updated.(model) if m.scanErr != nil { t.Fatal("successful scan should clear the previous scan error") } }