Files
larksuite-cli/internal/event/consume/loop_test.go
liuxinyanglxy 3f77eded9d feat: per-resource subscription identity + Match hook (#1185)
Framework support for resource-scoped event subscriptions, so one
EventKey can fan out into independent per-resource subscription scopes:

- KeyDefinition gains SubscriptionKey / NormalizeParams / Match hooks
- ComputeSubscriptionID derives a dedup identity from (EventKey, sub-key
  params); plumbed through bus Hub, consume loop, and the
  Hello / PreShutdownCheck / ConsumerInfo protocol messages
- add a synchronous Match filter stage before Process
- change PreConsume cleanup to func() error and surface cleanup
  (unsubscribe) failures as WARN with an idempotency note
- adapt minutes/vc/whiteboard PreConsume to the new cleanup signature
- render SubscriptionID / SubscriptionKey in event status & schema output

No domain wires these hooks yet; covered by unit tests using bus/protocol
doubles. (Mail, the original exerciser, is intentionally not included.)

Change-Id: Ifc743f1aa0bc4dff0c8a1e35da24883694fe7699
2026-06-11 16:22:04 +08:00

308 lines
8.4 KiB
Go

// 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)
}
})
}
}