package main import ( "context" "encoding/json" "html/template" "io" "net" "net/http" "net/http/httptest" "strings" "testing" pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) // --- Fake gRPC server --- type fakeServer struct { pb.UnimplementedLogtailServiceServer topNResp *pb.TopNResponse trendResp *pb.TrendResponse // Captured for inspection. lastTopN *pb.TopNRequest lastTrend *pb.TrendRequest } func (f *fakeServer) TopN(_ context.Context, req *pb.TopNRequest) (*pb.TopNResponse, error) { f.lastTopN = req if f.topNResp == nil { return &pb.TopNResponse{}, nil } return f.topNResp, nil } func (f *fakeServer) Trend(_ context.Context, req *pb.TrendRequest) (*pb.TrendResponse, error) { f.lastTrend = req if f.trendResp == nil { return &pb.TrendResponse{}, nil } return f.trendResp, nil } func startFake(t *testing.T, fs *fakeServer) string { t.Helper() lis, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } srv := grpc.NewServer() pb.RegisterLogtailServiceServer(srv, fs) go srv.Serve(lis) t.Cleanup(srv.GracefulStop) return lis.Addr().String() } func mustLoadTemplate(t *testing.T) *template.Template { t.Helper() funcMap := template.FuncMap{"fmtCount": fmtCount} tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html") if err != nil { t.Fatal(err) } return tmpl } func newHandler(t *testing.T, target string) *Handler { t.Helper() return &Handler{ defaultTarget: target, defaultN: 25, refreshSecs: 30, tmpl: mustLoadTemplate(t), } } func get(t *testing.T, h http.Handler, path string) (int, string) { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) body, _ := io.ReadAll(rec.Body) return rec.Code, string(body) } // --- Unit tests --- func TestParseWindowString(t *testing.T) { cases := []struct { in string wantPB pb.Window wantStr string }{ {"1m", pb.Window_W1M, "1m"}, {"5m", pb.Window_W5M, "5m"}, {"15m", pb.Window_W15M, "15m"}, {"60m", pb.Window_W60M, "60m"}, {"6h", pb.Window_W6H, "6h"}, {"24h", pb.Window_W24H, "24h"}, {"bad", pb.Window_W5M, "5m"}, // default {"", pb.Window_W5M, "5m"}, } for _, c := range cases { w, s := parseWindowString(c.in) if w != c.wantPB || s != c.wantStr { t.Errorf("parseWindowString(%q) = (%v, %q), want (%v, %q)", c.in, w, s, c.wantPB, c.wantStr) } } } func TestParseGroupByString(t *testing.T) { cases := []struct { in string wantPB pb.GroupBy wantStr string }{ {"website", pb.GroupBy_WEBSITE, "website"}, {"prefix", pb.GroupBy_CLIENT_PREFIX, "prefix"}, {"uri", pb.GroupBy_REQUEST_URI, "uri"}, {"status", pb.GroupBy_HTTP_RESPONSE, "status"}, {"bad", pb.GroupBy_WEBSITE, "website"}, // default } for _, c := range cases { g, s := parseGroupByString(c.in) if g != c.wantPB || s != c.wantStr { t.Errorf("parseGroupByString(%q) = (%v, %q), want (%v, %q)", c.in, g, s, c.wantPB, c.wantStr) } } } func TestParseQueryParams(t *testing.T) { h := &Handler{defaultTarget: "default:9091", defaultN: 25} req := httptest.NewRequest("GET", "/?target=other:9090&w=60m&by=prefix&n=10&f_website=example.com&f_status=429", nil) p := h.parseParams(req) if p.Target != "other:9090" { t.Errorf("target = %q", p.Target) } if p.WindowS != "60m" || p.Window != pb.Window_W60M { t.Errorf("window = %q / %v", p.WindowS, p.Window) } if p.GroupByS != "prefix" || p.GroupBy != pb.GroupBy_CLIENT_PREFIX { t.Errorf("group-by = %q / %v", p.GroupByS, p.GroupBy) } if p.N != 10 { t.Errorf("n = %d", p.N) } if p.Filter.Website != "example.com" { t.Errorf("f_website = %q", p.Filter.Website) } if p.Filter.Status != "429" { t.Errorf("f_status = %q", p.Filter.Status) } } func TestParseQueryParamsDefaults(t *testing.T) { h := &Handler{defaultTarget: "agg:9091", defaultN: 20} req := httptest.NewRequest("GET", "/", nil) p := h.parseParams(req) if p.Target != "agg:9091" { t.Errorf("default target = %q", p.Target) } if p.WindowS != "5m" { t.Errorf("default window = %q", p.WindowS) } if p.GroupByS != "website" { t.Errorf("default group-by = %q", p.GroupByS) } if p.N != 20 { t.Errorf("default n = %d", p.N) } } func TestBuildFilter(t *testing.T) { f := buildFilter(filterState{Website: "example.com", Status: "404"}) if f == nil { t.Fatal("expected non-nil filter") } if f.GetWebsite() != "example.com" { t.Errorf("website = %q", f.GetWebsite()) } if f.GetHttpResponse() != 404 { t.Errorf("status = %d", f.GetHttpResponse()) } if f.ClientPrefix != nil || f.HttpRequestUri != nil { t.Error("unexpected non-nil fields") } } func TestBuildFilterNil(t *testing.T) { if buildFilter(filterState{}) != nil { t.Error("expected nil when no filter set") } } func TestDrillURL(t *testing.T) { p := QueryParams{Target: "agg:9091", WindowS: "5m", GroupByS: "website", N: 25} u := p.drillURL("example.com") if !strings.Contains(u, "f_website=example.com") { t.Errorf("drill from website: missing f_website in %q", u) } if !strings.Contains(u, "by=prefix") { t.Errorf("drill from website: expected by=prefix in %q", u) } p.GroupByS = "prefix" u = p.drillURL("1.2.3.0/24") if !strings.Contains(u, "by=uri") { t.Errorf("drill from prefix: expected by=uri in %q", u) } p.GroupByS = "status" u = p.drillURL("429") if !strings.Contains(u, "f_status=429") { t.Errorf("drill from status: missing f_status in %q", u) } if !strings.Contains(u, "by=website") { t.Errorf("drill from status: expected cycle back to by=website in %q", u) } } func TestBuildCrumbs(t *testing.T) { p := QueryParams{ Target: "agg:9091", WindowS: "5m", GroupByS: "website", N: 25, Filter: filterState{Website: "example.com", Status: "429"}, } crumbs := buildCrumbs(p) if len(crumbs) != 2 { t.Fatalf("expected 2 crumbs, got %d", len(crumbs)) } if crumbs[0].Text != "website=example.com" { t.Errorf("crumb[0].text = %q", crumbs[0].Text) } // RemoveURL for website crumb should not contain f_website. if strings.Contains(crumbs[0].RemoveURL, "f_website") { t.Errorf("remove URL still has f_website: %q", crumbs[0].RemoveURL) } // RemoveURL for website crumb should still contain f_status. if !strings.Contains(crumbs[0].RemoveURL, "f_status=429") { t.Errorf("remove URL missing f_status: %q", crumbs[0].RemoveURL) } } func TestRenderSparkline(t *testing.T) { points := []*pb.TrendPoint{ {TimestampUnix: 1, Count: 100}, {TimestampUnix: 2, Count: 200}, {TimestampUnix: 3, Count: 150}, {TimestampUnix: 4, Count: 50}, {TimestampUnix: 5, Count: 300}, } svg := string(renderSparkline(points)) if !strings.Contains(svg, ""+w+"<") { t.Errorf("expected window tab %q in body", w) } } } func TestHandlerNoData(t *testing.T) { fake := &fakeServer{topNResp: &pb.TopNResponse{}} addr := startFake(t, fake) h := newHandler(t, addr) code, body := get(t, h, "/?target="+addr) if code != 200 { t.Fatalf("status = %d", code) } if !strings.Contains(body, "no data") { t.Error("expected 'no data' message in body") } } // Verify the fake gRPC server is reachable (sanity check for test infrastructure). func TestDialFake(t *testing.T) { addr := startFake(t, &fakeServer{}) conn, client, err := dial(addr) if err != nil { t.Fatal(err) } defer conn.Close() _ = client // If we got here without error, the fake server is reachable. _ = grpc.NewClient // suppress unused import warning in case of refactor _ = insecure.NewCredentials }