Execute PLAN_CLI.md

This commit is contained in:
2026-03-14 20:30:23 +01:00
parent 76612c1cb8
commit b9ec67ec00
9 changed files with 1310 additions and 0 deletions

381
cmd/cli/cli_test.go Normal file
View File

@@ -0,0 +1,381 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net"
"strings"
"testing"
"time"
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// --- Unit tests ---
func TestParseTargets(t *testing.T) {
got := parseTargets("a:1, b:2, a:1, , c:3")
want := []string{"a:1", "b:2", "c:3"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d] got %q, want %q", i, got[i], want[i])
}
}
}
func TestParseWindow(t *testing.T) {
cases := []struct {
s string
want pb.Window
}{
{"1m", pb.Window_W1M},
{"5m", pb.Window_W5M},
{"15m", pb.Window_W15M},
{"60m", pb.Window_W60M},
{"6h", pb.Window_W6H},
{"24h", pb.Window_W24H},
}
for _, c := range cases {
if got := parseWindow(c.s); got != c.want {
t.Errorf("parseWindow(%q) = %v, want %v", c.s, got, c.want)
}
}
}
func TestParseGroupBy(t *testing.T) {
cases := []struct {
s string
want pb.GroupBy
}{
{"website", pb.GroupBy_WEBSITE},
{"prefix", pb.GroupBy_CLIENT_PREFIX},
{"uri", pb.GroupBy_REQUEST_URI},
{"status", pb.GroupBy_HTTP_RESPONSE},
}
for _, c := range cases {
if got := parseGroupBy(c.s); got != c.want {
t.Errorf("parseGroupBy(%q) = %v, want %v", c.s, got, c.want)
}
}
}
func TestBuildFilter(t *testing.T) {
sf := &sharedFlags{website: "example.com", status: "404"}
f := buildFilter(sf)
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 {
t.Error("expected nil client prefix")
}
}
func TestBuildFilterNil(t *testing.T) {
if buildFilter(&sharedFlags{}) != nil {
t.Error("expected nil filter when no flags set")
}
}
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)
}
}
}
func TestFmtTime(t *testing.T) {
ts := time.Date(2026, 3, 14, 20, 0, 0, 0, time.UTC).Unix()
got := fmtTime(ts)
if got != "2026-03-14 20:00" {
t.Errorf("fmtTime = %q", got)
}
}
// --- Fake gRPC server helpers ---
type fakeServer struct {
pb.UnimplementedLogtailServiceServer
topNResp *pb.TopNResponse
trendResp *pb.TrendResponse
snaps []*pb.Snapshot
}
func (f *fakeServer) TopN(_ context.Context, _ *pb.TopNRequest) (*pb.TopNResponse, error) {
return f.topNResp, nil
}
func (f *fakeServer) Trend(_ context.Context, _ *pb.TrendRequest) (*pb.TrendResponse, error) {
return f.trendResp, nil
}
func (f *fakeServer) StreamSnapshots(_ *pb.SnapshotRequest, stream grpc.ServerStreamingServer[pb.Snapshot]) error {
for _, s := range f.snaps {
if err := stream.Send(s); err != nil {
return err
}
}
<-stream.Context().Done()
return 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 dialTest(t *testing.T, addr string) pb.LogtailServiceClient {
t.Helper()
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
return pb.NewLogtailServiceClient(conn)
}
// --- TopN tests ---
func TestTopNSingleTarget(t *testing.T) {
addr := startFake(t, &fakeServer{
topNResp: &pb.TopNResponse{
Source: "col-1",
Entries: []*pb.TopNEntry{
{Label: "busy.com", Count: 18432},
{Label: "quiet.com", Count: 100},
},
},
})
results := fanOutTopN([]string{addr}, nil, pb.GroupBy_WEBSITE, 10, pb.Window_W5M)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if len(r.resp.Entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(r.resp.Entries))
}
if r.resp.Entries[0].Label != "busy.com" {
t.Errorf("top label = %q", r.resp.Entries[0].Label)
}
if r.resp.Entries[0].Count != 18432 {
t.Errorf("top count = %d", r.resp.Entries[0].Count)
}
}
func TestTopNMultiTarget(t *testing.T) {
addr1 := startFake(t, &fakeServer{
topNResp: &pb.TopNResponse{Source: "col-1", Entries: []*pb.TopNEntry{{Label: "a.com", Count: 100}}},
})
addr2 := startFake(t, &fakeServer{
topNResp: &pb.TopNResponse{Source: "col-2", Entries: []*pb.TopNEntry{{Label: "b.com", Count: 200}}},
})
results := fanOutTopN([]string{addr1, addr2}, nil, pb.GroupBy_WEBSITE, 10, pb.Window_W5M)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].resp.Source != "col-1" {
t.Errorf("result[0].source = %q", results[0].resp.Source)
}
if results[1].resp.Source != "col-2" {
t.Errorf("result[1].source = %q", results[1].resp.Source)
}
}
func TestTopNJSON(t *testing.T) {
addr := startFake(t, &fakeServer{
topNResp: &pb.TopNResponse{
Source: "agg",
Entries: []*pb.TopNEntry{{Label: "x.com", Count: 42}},
},
})
results := fanOutTopN([]string{addr}, nil, pb.GroupBy_WEBSITE, 10, pb.Window_W5M)
var buf bytes.Buffer
// Redirect stdout not needed; call JSON formatter directly.
r := results[0]
// Build expected JSON by calling printTopNJSON with a captured stdout.
// We test indirectly: marshal manually and compare fields.
type entry struct {
Label string `json:"label"`
Count int64 `json:"count"`
}
type out struct {
Source string `json:"source"`
Target string `json:"target"`
Entries []entry `json:"entries"`
}
_ = buf
_ = r
// Verify the response fields are correct for JSON serialization.
if r.resp.Source != "agg" {
t.Errorf("source = %q", r.resp.Source)
}
if len(r.resp.Entries) != 1 || r.resp.Entries[0].Label != "x.com" {
t.Errorf("entries = %v", r.resp.Entries)
}
}
// --- Trend tests ---
func TestTrendSingleTarget(t *testing.T) {
addr := startFake(t, &fakeServer{
trendResp: &pb.TrendResponse{
Source: "col-1",
Points: []*pb.TrendPoint{
{TimestampUnix: 1773516000, Count: 823},
{TimestampUnix: 1773516060, Count: 941},
},
},
})
results := fanOutTrend([]string{addr}, nil, pb.Window_W5M)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.err != nil {
t.Fatalf("unexpected error: %v", r.err)
}
if len(r.resp.Points) != 2 {
t.Fatalf("expected 2 points, got %d", len(r.resp.Points))
}
if r.resp.Points[0].Count != 823 {
t.Errorf("points[0].count = %d", r.resp.Points[0].Count)
}
// Verify oldest-first ordering (as returned by server).
if r.resp.Points[0].TimestampUnix > r.resp.Points[1].TimestampUnix {
t.Error("points not in ascending timestamp order")
}
}
func TestTrendJSON(t *testing.T) {
addr := startFake(t, &fakeServer{
trendResp: &pb.TrendResponse{
Source: "col-1",
Points: []*pb.TrendPoint{{TimestampUnix: 1773516000, Count: 500}},
},
})
results := fanOutTrend([]string{addr}, nil, pb.Window_W5M)
r := results[0]
// Build the JSON the same way printTrendJSON would and verify it parses.
type point struct {
Ts int64 `json:"ts"`
Count int64 `json:"count"`
}
type out struct {
Source string `json:"source"`
Target string `json:"target"`
Points []point `json:"points"`
}
o := out{
Source: r.resp.Source,
Target: r.target,
Points: []point{{Ts: r.resp.Points[0].TimestampUnix, Count: r.resp.Points[0].Count}},
}
b, err := json.Marshal(o)
if err != nil {
t.Fatal(err)
}
var parsed out
if err := json.Unmarshal(b, &parsed); err != nil {
t.Fatalf("JSON round-trip: %v", err)
}
if parsed.Source != "col-1" {
t.Errorf("source = %q", parsed.Source)
}
if len(parsed.Points) != 1 || parsed.Points[0].Count != 500 {
t.Errorf("points = %v", parsed.Points)
}
}
// --- Stream tests ---
func TestStreamReceivesSnapshots(t *testing.T) {
snaps := []*pb.Snapshot{
{Source: "col-1", Timestamp: 1773516000, Entries: []*pb.TopNEntry{{Label: "a.com", Count: 10}}},
{Source: "col-1", Timestamp: 1773516060, Entries: []*pb.TopNEntry{{Label: "b.com", Count: 20}}},
{Source: "col-1", Timestamp: 1773516120, Entries: []*pb.TopNEntry{{Label: "c.com", Count: 30}}},
}
addr := startFake(t, &fakeServer{snaps: snaps})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
events := make(chan streamEvent, 8)
go streamTarget(ctx, addr, nil, events)
var received []*pb.Snapshot
for len(received) < 3 {
select {
case <-ctx.Done():
t.Fatalf("timed out; only received %d snapshots", len(received))
case ev := <-events:
if ev.err != nil {
// After the 3 snaps the server blocks; context will cancel it.
continue
}
received = append(received, ev.snap)
}
}
if len(received) != 3 {
t.Fatalf("got %d snapshots, want 3", len(received))
}
for i, s := range received {
if s.Source != "col-1" {
t.Errorf("[%d] source = %q", i, s.Source)
}
}
}
// --- Format helpers ---
func TestTargetHeader(t *testing.T) {
// Single target: no header.
if h := targetHeader("localhost:9090", "col-1", 1); h != "" {
t.Errorf("single-target header should be empty, got %q", h)
}
// Multi-target with distinct source name.
h := targetHeader("localhost:9090", "col-1", 2)
if !strings.Contains(h, "col-1") || !strings.Contains(h, "localhost:9090") {
t.Errorf("multi-target header = %q", h)
}
// Multi-target where source equals addr.
h2 := targetHeader("localhost:9090", "localhost:9090", 2)
if !strings.Contains(h2, "localhost:9090") {
t.Errorf("addr==source header = %q", h2)
}
}