533 lines
13 KiB
Go
533 lines
13 KiB
Go
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, "<svg") {
|
||
t.Error("expected <svg tag")
|
||
}
|
||
if !strings.Contains(svg, "<polyline") {
|
||
t.Error("expected <polyline tag")
|
||
}
|
||
if !strings.Contains(svg, "points=") {
|
||
t.Error("expected points= attribute")
|
||
}
|
||
}
|
||
|
||
func TestRenderSparklineTooFewPoints(t *testing.T) {
|
||
if renderSparkline(nil) != "" {
|
||
t.Error("nil → expected empty")
|
||
}
|
||
if renderSparkline([]*pb.TrendPoint{{Count: 100}}) != "" {
|
||
t.Error("1 point → expected empty")
|
||
}
|
||
}
|
||
|
||
func TestRenderSparklineAllZero(t *testing.T) {
|
||
points := []*pb.TrendPoint{{Count: 0}, {Count: 0}, {Count: 0}}
|
||
if renderSparkline(points) != "" {
|
||
t.Error("all-zero → expected empty")
|
||
}
|
||
}
|
||
|
||
func TestFmtCount(t *testing.T) {
|
||
cases := []struct {
|
||
n int64
|
||
want string
|
||
}{
|
||
{0, "0"},
|
||
{999, "999"},
|
||
{1000, "1 000"},
|
||
{18432, "18 432"},
|
||
{1234567, "1 234 567"},
|
||
}
|
||
for _, c := range cases {
|
||
if got := fmtCount(c.n); got != c.want {
|
||
t.Errorf("fmtCount(%d) = %q, want %q", c.n, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Handler integration tests ---
|
||
|
||
func TestHandlerTopN(t *testing.T) {
|
||
fake := &fakeServer{
|
||
topNResp: &pb.TopNResponse{
|
||
Source: "col-1",
|
||
Entries: []*pb.TopNEntry{
|
||
{Label: "busy.com", Count: 18432},
|
||
{Label: "quiet.com", Count: 100},
|
||
},
|
||
},
|
||
}
|
||
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, "busy.com") {
|
||
t.Error("expected busy.com in body")
|
||
}
|
||
if !strings.Contains(body, "quiet.com") {
|
||
t.Error("expected quiet.com in body")
|
||
}
|
||
if !strings.Contains(body, "18 432") {
|
||
t.Error("expected formatted count 18 432 in body")
|
||
}
|
||
}
|
||
|
||
func TestHandlerRaw(t *testing.T) {
|
||
fake := &fakeServer{
|
||
topNResp: &pb.TopNResponse{
|
||
Source: "agg",
|
||
Entries: []*pb.TopNEntry{{Label: "x.com", Count: 42}},
|
||
},
|
||
}
|
||
addr := startFake(t, fake)
|
||
h := newHandler(t, addr)
|
||
|
||
code, body := get(t, h, "/?target="+addr+"&raw=1&w=15m&by=prefix")
|
||
if code != 200 {
|
||
t.Fatalf("status = %d", code)
|
||
}
|
||
|
||
var result struct {
|
||
Source string `json:"source"`
|
||
Window string `json:"window"`
|
||
GroupBy string `json:"group_by"`
|
||
Entries []struct {
|
||
Label string `json:"label"`
|
||
Count int64 `json:"count"`
|
||
} `json:"entries"`
|
||
}
|
||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||
t.Fatalf("JSON parse: %v\nbody: %s", err, body)
|
||
}
|
||
if result.Source != "agg" {
|
||
t.Errorf("source = %q", result.Source)
|
||
}
|
||
if result.Window != "15m" {
|
||
t.Errorf("window = %q", result.Window)
|
||
}
|
||
if result.GroupBy != "prefix" {
|
||
t.Errorf("group_by = %q", result.GroupBy)
|
||
}
|
||
if len(result.Entries) != 1 || result.Entries[0].Label != "x.com" {
|
||
t.Errorf("entries = %+v", result.Entries)
|
||
}
|
||
}
|
||
|
||
func TestHandlerBadTarget(t *testing.T) {
|
||
h := newHandler(t, "127.0.0.1:1") // always refused
|
||
code, body := get(t, h, "/?target=127.0.0.1:1")
|
||
if code != http.StatusBadGateway {
|
||
t.Errorf("status = %d, want 502", code)
|
||
}
|
||
if !strings.Contains(body, "error") && !strings.Contains(body, "Error") {
|
||
t.Error("expected error message in body")
|
||
}
|
||
}
|
||
|
||
func TestHandlerFilterPassedToServer(t *testing.T) {
|
||
fake := &fakeServer{}
|
||
addr := startFake(t, fake)
|
||
h := newHandler(t, addr)
|
||
|
||
get(t, h, "/?target="+addr+"&f_website=example.com&f_status=429")
|
||
|
||
if fake.lastTopN == nil {
|
||
t.Fatal("no TopN request received")
|
||
}
|
||
f := fake.lastTopN.Filter
|
||
if f == nil {
|
||
t.Fatal("filter is nil")
|
||
}
|
||
if f.GetWebsite() != "example.com" {
|
||
t.Errorf("website = %q", f.GetWebsite())
|
||
}
|
||
if f.GetHttpResponse() != 429 {
|
||
t.Errorf("status = %d", f.GetHttpResponse())
|
||
}
|
||
}
|
||
|
||
func TestHandlerWindowPassedToServer(t *testing.T) {
|
||
fake := &fakeServer{}
|
||
addr := startFake(t, fake)
|
||
h := newHandler(t, addr)
|
||
|
||
get(t, h, "/?target="+addr+"&w=60m")
|
||
|
||
if fake.lastTopN == nil {
|
||
t.Fatal("no TopN request received")
|
||
}
|
||
if fake.lastTopN.Window != pb.Window_W60M {
|
||
t.Errorf("window = %v, want W60M", fake.lastTopN.Window)
|
||
}
|
||
}
|
||
|
||
func TestHandlerBreadcrumbInHTML(t *testing.T) {
|
||
fake := &fakeServer{topNResp: &pb.TopNResponse{}}
|
||
addr := startFake(t, fake)
|
||
h := newHandler(t, addr)
|
||
|
||
_, body := get(t, h, "/?target="+addr+"&f_website=example.com")
|
||
|
||
if !strings.Contains(body, "website=example.com") {
|
||
t.Error("expected breadcrumb with website=example.com")
|
||
}
|
||
// Remove link should exist.
|
||
if !strings.Contains(body, "×") {
|
||
t.Error("expected × remove link in breadcrumb")
|
||
}
|
||
}
|
||
|
||
func TestHandlerSparklineInHTML(t *testing.T) {
|
||
fake := &fakeServer{
|
||
topNResp: &pb.TopNResponse{Entries: []*pb.TopNEntry{{Label: "x.com", Count: 1}}},
|
||
trendResp: &pb.TrendResponse{Points: []*pb.TrendPoint{
|
||
{TimestampUnix: 1, Count: 100},
|
||
{TimestampUnix: 2, Count: 200},
|
||
{TimestampUnix: 3, Count: 150},
|
||
}},
|
||
}
|
||
addr := startFake(t, fake)
|
||
h := newHandler(t, addr)
|
||
|
||
_, body := get(t, h, "/?target="+addr)
|
||
|
||
if !strings.Contains(body, "<svg") {
|
||
t.Error("expected SVG sparkline in body")
|
||
}
|
||
if !strings.Contains(body, "<polyline") {
|
||
t.Error("expected polyline in SVG sparkline")
|
||
}
|
||
}
|
||
|
||
func TestHandlerPctBar(t *testing.T) {
|
||
fake := &fakeServer{
|
||
topNResp: &pb.TopNResponse{
|
||
Entries: []*pb.TopNEntry{
|
||
{Label: "top.com", Count: 1000},
|
||
{Label: "half.com", Count: 500},
|
||
},
|
||
},
|
||
}
|
||
addr := startFake(t, fake)
|
||
h := newHandler(t, addr)
|
||
|
||
_, body := get(t, h, "/?target="+addr)
|
||
|
||
if !strings.Contains(body, "<meter") {
|
||
t.Error("expected <meter bar in body")
|
||
}
|
||
// top.com should be 100%, half.com 50%.
|
||
if !strings.Contains(body, `value="100"`) {
|
||
t.Error("expected 100% bar for top entry")
|
||
}
|
||
if !strings.Contains(body, `value="50"`) {
|
||
t.Error("expected 50% bar for half entry")
|
||
}
|
||
}
|
||
|
||
func TestHandlerWindowTabsInHTML(t *testing.T) {
|
||
fake := &fakeServer{}
|
||
addr := startFake(t, fake)
|
||
h := newHandler(t, addr)
|
||
|
||
_, body := get(t, h, "/?target="+addr+"&w=15m")
|
||
|
||
// All window labels should appear.
|
||
for _, w := range []string{"1m", "5m", "15m", "60m", "6h", "24h"} {
|
||
if !strings.Contains(body, ">"+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
|
||
}
|