// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package consume import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "strings" "sync/atomic" "syscall" "testing" "time" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/protocol" ) func echoKeyDef(key string) *event.KeyDefinition { return &event.KeyDefinition{ Key: key, EventType: key, BufferSize: 32, Workers: 1, Process: func(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { return raw.Payload, nil }, } } func busSide(t *testing.T, server net.Conn, events []*protocol.Event, ackLast bool) { t.Helper() for _, evt := range events { if err := protocol.Encode(server, evt); err != nil { return } } br := bufio.NewReader(server) deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { _ = server.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) line, err := protocol.ReadFrame(br) if err != nil { var ne net.Error if errors.As(err, &ne) && ne.Timeout() { continue } return } msg, decErr := protocol.Decode(bytes.TrimRight(line, "\n")) if decErr != nil { continue } if _, ok := msg.(*protocol.PreShutdownCheck); ok { _ = protocol.Encode(server, protocol.NewPreShutdownAck(ackLast)) return } } } func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) { client, server := net.Pipe() defer client.Close() defer server.Close() events := []*protocol.Event{ protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)), protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"n":2}`)), } go busSide(t, server, events, true) var stdout bytes.Buffer opts := Options{ EventKey: "test.key", Out: &stdout, ErrOut: io.Discard, Quiet: true, MaxEvents: 2, } var lastForKey bool var emitted atomic.Int64 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted) if err != nil { t.Fatalf("consumeLoop: %v", err) } if got := emitted.Load(); got != 2 { t.Errorf("emitted = %d, want 2", got) } if !lastForKey { t.Error("lastForKey = false, want true (bus acked LastForKey=true)") } out := stdout.String() for _, want := range []string{`{"n":1}`, `{"n":2}`} { if !strings.Contains(out, want) { t.Errorf("stdout missing %q; full:\n%s", want, out) } } } func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) { client, server := net.Pipe() defer client.Close() defer server.Close() events := []*protocol.Event{ protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)), protocol.NewEvent("test.evt", "e5", "", 5, json.RawMessage(`{"n":5}`)), } go busSide(t, server, events, true) var stdout, stderr bytes.Buffer opts := Options{ EventKey: "test.key", Out: &stdout, ErrOut: &stderr, Quiet: false, MaxEvents: 2, } var lastForKey bool var emitted atomic.Int64 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil { t.Fatalf("consumeLoop: %v", err) } if got := emitted.Load(); got != 2 { t.Errorf("emitted = %d, want 2", got) } if !strings.Contains(stderr.String(), "WARN: event seq gap 1->5") { t.Errorf("stderr missing seq-gap warning; got:\n%s", stderr.String()) } } func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) { client, server := net.Pipe() defer client.Close() defer server.Close() events := []*protocol.Event{ protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"keep":true,"n":1}`)), protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"keep":false,"n":2}`)), } go busSide(t, server, events, true) var stdout bytes.Buffer opts := Options{ EventKey: "test.key", Out: &stdout, ErrOut: io.Discard, Quiet: true, JQExpr: "select(.keep) | .n", MaxEvents: 1, } var lastForKey bool var emitted atomic.Int64 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil { t.Fatalf("consumeLoop: %v", err) } if got := emitted.Load(); got != 1 { t.Errorf("emitted = %d, want 1", got) } out := strings.TrimSpace(stdout.String()) if out != "1" { t.Errorf("stdout = %q, want %q", out, "1") } } func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) { client, server := net.Pipe() defer client.Close() defer server.Close() opts := Options{ EventKey: "test.key", Out: io.Discard, ErrOut: io.Discard, Quiet: true, JQExpr: "not a valid jq expression (((", } var lastForKey bool var emitted atomic.Int64 err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted) if err == nil { t.Fatal("consumeLoop should fail immediately on bad jq expression") } } // captureSink is a minimal Sink for unit-testing processAndOutput directly. type captureSink struct { written []json.RawMessage } func (s *captureSink) Write(data json.RawMessage) error { s.written = append(s.written, data) return nil } func TestProcessAndOutput_Match_DropsEvent(t *testing.T) { calledProcess := false keyDef := &event.KeyDefinition{ Key: "test.evt", Match: func(raw *event.RawEvent, params map[string]string) bool { return false }, Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) { calledProcess = true return json.RawMessage(`{}`), nil }, } sink := &captureSink{} wrote, err := processAndOutput(context.Background(), keyDef, &protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)}, Options{}, sink, nil) if err != nil { t.Fatal(err) } if wrote { t.Error("Match returned false but event was written") } if calledProcess { t.Error("Process was called even though Match returned false") } if len(sink.written) != 0 { t.Errorf("sink received %d events, want 0", len(sink.written)) } } func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) { keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process sink := &captureSink{} wrote, err := processAndOutput(context.Background(), keyDef, &protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)}, Options{}, sink, nil) if err != nil || !wrote { t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err) } if len(sink.written) != 1 { t.Errorf("sink received %d events, want 1", len(sink.written)) } } func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) { // Record the actual call sequence — a bare call-count check would still // pass if Process ran before Match. var order []string keyDef := &event.KeyDefinition{ Key: "test.evt", Match: func(raw *event.RawEvent, params map[string]string) bool { order = append(order, "match") return true }, Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) { order = append(order, "process") return raw.Payload, nil }, } sink := &captureSink{} wrote, err := processAndOutput(context.Background(), keyDef, &protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)}, Options{}, sink, nil) if err != nil { t.Fatal(err) } if !wrote { t.Error("expected wrote=true") } if len(order) != 2 || order[0] != "match" || order[1] != "process" { t.Errorf("call order = %v, want [match process]", order) } } func TestIsTerminalSinkError(t *testing.T) { for _, tc := range []struct { name string err error want bool }{ {"nil", nil, false}, {"EPIPE raw", syscall.EPIPE, true}, {"EPIPE wrapped", fmt.Errorf("write: %w", syscall.EPIPE), true}, {"ErrClosed", io.ErrClosedPipe, false}, {"transient disk full", errors.New("no space left on device"), false}, } { t.Run(tc.name, func(t *testing.T) { if got := isTerminalSinkError(tc.err); got != tc.want { t.Errorf("isTerminalSinkError(%v) = %v, want %v", tc.err, got, tc.want) } }) } }