package issues import "testing" func TestTrackerDedupesByFingerprint(t *testing.T) { tracker := NewTracker() tracker.Record("span_error", "checkout", "charge-card: card declined", Sample{Timestamp: 100, TraceID: "t1"}) tracker.Record("span_error", "checkout", "charge-card: card declined", Sample{Timestamp: 200, TraceID: "t2"}) tracker.Record("span_error", "checkout", "different error", Sample{Timestamp: 150}) list := tracker.List(0) if len(list) != 2 { t.Fatalf("expected 2 distinct issues, got %d", len(list)) } // Most recently seen first. if list[0].Title != "charge-card: card declined" { t.Errorf("expected most recent issue first, got %q", list[0].Title) } if list[0].Count != 2 { t.Errorf("expected duplicate occurrences to merge into count 2, got %d", list[0].Count) } if list[0].FirstSeen != 100 || list[0].LastSeen != 200 { t.Errorf("expected firstSeen=100 lastSeen=200, got firstSeen=%d lastSeen=%d", list[0].FirstSeen, list[0].LastSeen) } if len(list[0].Samples) != 2 { t.Errorf("expected 2 samples, got %d", len(list[0].Samples)) } } func TestTrackerGet(t *testing.T) { tracker := NewTracker() tracker.Record("log_error", "api", "panic: nil pointer", Sample{Timestamp: 100}) list := tracker.List(0) if len(list) != 1 { t.Fatalf("expected 1 issue, got %d", len(list)) } issue, ok := tracker.Get(list[0].ID) if !ok { t.Fatalf("expected to find issue %s", list[0].ID) } if issue.Service != "api" { t.Errorf("expected service 'api', got %s", issue.Service) } if _, ok := tracker.Get("does-not-exist"); ok { t.Errorf("expected lookup of unknown id to fail") } } func TestTrackerDifferentServicesDoNotMerge(t *testing.T) { tracker := NewTracker() tracker.Record("span_error", "checkout", "timeout", Sample{Timestamp: 100}) tracker.Record("span_error", "billing", "timeout", Sample{Timestamp: 100}) list := tracker.List(0) if len(list) != 2 { t.Fatalf("expected same title on different services to stay distinct, got %d issues", len(list)) } } func TestTrackerListRespectsLimit(t *testing.T) { tracker := NewTracker() for i := 0; i < 5; i++ { tracker.Record("log_error", "api", string(rune('a'+i)), Sample{Timestamp: int64(i)}) } list := tracker.List(2) if len(list) != 2 { t.Fatalf("expected limit to cap results at 2, got %d", len(list)) } } func TestTrackerFilteredCursorPagination(t *testing.T) { tracker := NewTracker() tracker.Record("span_error", "nginx", "GET /.env", Sample{Timestamp: 300, SourceIP: "203.0.113.10", HTTPRoute: "/.env"}) tracker.Record("span_error", "nginx", "GET /.git/HEAD", Sample{Timestamp: 200, SourceIP: "203.0.113.11", HTTPRoute: "/.git/HEAD"}) tracker.Record("log_error", "api", "database down", Sample{Timestamp: 100}) page := tracker.ListFiltered(ListOptions{Service: "nginx", Kind: "span_error", Route: "/.env", Limit: 1}) if len(page.Issues) != 1 || page.Issues[0].Title != "GET /.env" { t.Fatalf("unexpected filtered page: %+v", page) } if page.HasMore { t.Error("single matching issue should not have another page") } all := tracker.ListFiltered(ListOptions{Service: "nginx", Limit: 1}) if len(all.Issues) != 1 || !all.HasMore { t.Fatalf("expected first paginated page with more results, got %+v", all) } next := tracker.ListFiltered(ListOptions{ Service: "nginx", AfterLastSeen: all.Issues[0].LastSeen, AfterID: all.Issues[0].ID, Limit: 1, }) if len(next.Issues) != 1 || next.Issues[0].Title != "GET /.git/HEAD" || next.HasMore { t.Fatalf("unexpected cursor page: %+v", next) } } func TestNormalizeTitleGroupsVariableMessages(t *testing.T) { cases := [][3]string{ // same, same, different: [a, b, expectedEqual(a,b)] {"User 123 not found", "User 456 not found", "user not found"}, {"order 550e8400-e29b-41d4-a716-446655440000 failed", "order 550e8400-e29b-41d4-a716-446655440001 failed", "order failed"}, {"request 0a1b2c3d4e5f6a7b timed out", "request ff11aa22bb33cc44 timed out", "request timed out"}, } for _, c := range cases { if got := NormalizeTitle(c[0]); got != c[2] { t.Errorf("NormalizeTitle(%q) = %q, want %q", c[0], got, c[2]) } if NormalizeTitle(c[0]) != NormalizeTitle(c[1]) { t.Errorf("expected %q and %q to normalize identically", c[0], c[1]) } } // Words spelled from hex letters without digits must survive. if got := NormalizeTitle("bad failed deadbeef badge"); got != "bad failed deadbeef badge" { t.Errorf("word-like hex incorrectly replaced: %q", got) } } func TestTrackerNormalizedFingerprinting(t *testing.T) { tracker := NewTracker() tracker.Record("log_error", "api", "User 123 not found", Sample{Timestamp: 100, Message: "User 123 not found"}) tracker.Record("log_error", "api", "User 98765432 not found", Sample{Timestamp: 200, Message: "User 98765432 not found"}) list := tracker.List(0) if len(list) != 1 { t.Fatalf("expected variable messages to merge into 1 issue, got %d", len(list)) } if list[0].Count != 2 { t.Errorf("expected count 2, got %d", list[0].Count) } // The display title is the first-seen raw message. if list[0].Title != "User 123 not found" { t.Errorf("expected first-seen raw title kept, got %q", list[0].Title) } } func TestTrackerTrendBuckets(t *testing.T) { tracker := NewTracker() // Two occurrences in the same hour. tracker.Record("log_error", "api", "boom", Sample{Timestamp: 100}) tracker.Record("log_error", "api", "boom", Sample{Timestamp: 200}) issue, ok := tracker.Get(tracker.List(1)[0].ID) if !ok { t.Fatal("expected issue to exist") } bucket := HourBucket(100) if issue.Trend[bucket] != 2 { t.Errorf("expected trend[%s]=2, got %d", bucket, issue.Trend[bucket]) } } func TestTrackerStatusLifecycle(t *testing.T) { tracker := NewTracker() tracker.Record("log_error", "api", "boom", Sample{Timestamp: 100}) id := tracker.List(1)[0].ID // Invalid status rejected. if _, ok := tracker.SetStatus(id, "bogus"); ok { t.Error("expected invalid status to be rejected") } // Unknown id rejected. if _, ok := tracker.SetStatus("nope", StatusResolved); ok { t.Error("expected unknown id to be rejected") } // Resolve. resolved, ok := tracker.SetStatus(id, StatusResolved) if !ok || resolved.Status != StatusResolved || resolved.ResolvedAt == 0 { t.Fatalf("expected resolved issue, got %+v ok=%v", resolved, ok) } // A new occurrence reopens it (regression). tracker.Record("log_error", "api", "boom", Sample{Timestamp: 200}) issue, _ := tracker.Get(id) if issue.Status != StatusOpen || issue.ResolvedAt != 0 { t.Errorf("expected regression to reopen issue, got status=%s resolvedAt=%d", issue.Status, issue.ResolvedAt) } // Ignored survives new occurrences. if _, ok := tracker.SetStatus(id, StatusIgnored); !ok { t.Fatal("expected ignore to succeed") } tracker.Record("log_error", "api", "boom", Sample{Timestamp: 300}) issue, _ = tracker.Get(id) if issue.Status != StatusIgnored { t.Errorf("expected ignored state to survive new occurrence, got %s", issue.Status) } } func TestTrackerPatternsAndSimilarSuggestions(t *testing.T) { tracker := NewTracker() tracker.Record("span_error", "checkout", "HTTP 504: database timeout for order 123", Sample{ Timestamp: 100, HTTPRoute: "/orders/123", HTTPStatus: 504, Stacktrace: "db.go:42\nhandler.go:10", }) tracker.Record("span_error", "checkout", "HTTP 502: database timeout for order 456", Sample{ Timestamp: 200, HTTPRoute: "/orders/456", HTTPStatus: 502, Stacktrace: "db.go:42\nhandler.go:10", }) issuesList := tracker.List(0) if len(issuesList) != 2 { t.Fatalf("expected separate exact issues, got %d", len(issuesList)) } if issuesList[0].PatternID == "" || issuesList[0].PatternID != issuesList[1].PatternID { t.Fatalf("expected related HTTP variants to share a pattern: %+v", issuesList) } if issuesList[0].Samples[0].NormalizedRoute != "/orders/{id}" { t.Fatalf("expected normalized route, got %q", issuesList[0].Samples[0].NormalizedRoute) } related, ok := tracker.Similar(issuesList[0].ID, 5) if !ok || len(related) != 1 || related[0].Score < .45 { t.Fatalf("expected deterministic related suggestion, got ok=%v related=%+v", ok, related) } if !tracker.SetSimilarityFeedback(issuesList[0].ID, related[0].Issue.ID, "same") { t.Fatal("expected feedback to be accepted") } related, _ = tracker.Similar(issuesList[0].ID, 5) if related[0].Decision != "same" { t.Fatalf("expected feedback decision, got %q", related[0].Decision) } patterns := tracker.Patterns() if len(patterns) != 1 || patterns[0].IssueCount != 2 || patterns[0].OccurrenceCount != 2 { t.Fatalf("expected one two-issue pattern, got %+v", patterns) } }