361 lines
11 KiB
Go
361 lines
11 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package consume
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
|
)
|
|
|
|
type consumeReadyWriter struct {
|
|
ready chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func newConsumeReadyWriter() *consumeReadyWriter {
|
|
return &consumeReadyWriter{ready: make(chan struct{})}
|
|
}
|
|
|
|
func (w *consumeReadyWriter) Write(p []byte) (int, error) {
|
|
if bytes.Contains(p, []byte("[event] ready ")) {
|
|
w.once.Do(func() { close(w.ready) })
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func waitForConsumerReady(t *testing.T, ready <-chan struct{}) {
|
|
t.Helper()
|
|
select {
|
|
case <-ready:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("consumer did not report ready")
|
|
}
|
|
}
|
|
|
|
// TestIntegration_HelloPushdownFiltersAtBus verifies the Hello-time
|
|
// event_types pushdown contract (plan §4 unsung superpower): a consumer
|
|
// subscribing to "im.*" must NOT receive "approval.*" events even when
|
|
// bus and source are flowing both. The filter happens at the Hub layer,
|
|
// not at the consumer pipeline — saves IPC bytes for narrow consumers.
|
|
func TestIntegration_HelloPushdownFiltersAtBus(t *testing.T) {
|
|
skipOnWindows(t)
|
|
events := []dwsevent.RawEvent{
|
|
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
|
{EventID: "2", EventType: "approval.task", Data: `{}`},
|
|
{EventID: "3", EventType: "im.message.at_v1", Data: `{}`},
|
|
{EventID: "4", EventType: "approval.instance.status_changed", Data: `{}`},
|
|
{EventID: "5", EventType: "im.chat.member.user.added_v1", Data: `{}`},
|
|
}
|
|
dir, sock, cancel, runDone, trigger := bringUpBus(t, events)
|
|
defer func() { cancel(); <-runDone }()
|
|
|
|
var imBuf, approvalBuf bytes.Buffer
|
|
imReady := newConsumeReadyWriter()
|
|
approvalReady := newConsumeReadyWriter()
|
|
consumeCtx, cancelConsumers := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancelConsumers()
|
|
consumeDone := make(chan error, 2)
|
|
|
|
// Consumer A: im.* only
|
|
go func() {
|
|
consumeDone <- Run(consumeCtx, Config{
|
|
WorkDir: dir,
|
|
IPCEndpoint: sock,
|
|
ClientID: "ding_test",
|
|
Stdout: &imBuf,
|
|
Stderr: imReady,
|
|
EventTypes: []string{"im.*"},
|
|
MaxEvents: 3, // 3 im events expected
|
|
})
|
|
}()
|
|
// Consumer B: approval.* only
|
|
go func() {
|
|
consumeDone <- Run(consumeCtx, Config{
|
|
WorkDir: dir,
|
|
IPCEndpoint: sock,
|
|
ClientID: "ding_test",
|
|
Stdout: &approvalBuf,
|
|
Stderr: approvalReady,
|
|
EventTypes: []string{"approval.*"},
|
|
MaxEvents: 2, // 2 approval events expected
|
|
})
|
|
}()
|
|
|
|
waitForConsumerReady(t, imReady.ready)
|
|
waitForConsumerReady(t, approvalReady.ready)
|
|
close(trigger)
|
|
for range 2 {
|
|
select {
|
|
case err := <-consumeDone:
|
|
if err != nil {
|
|
t.Fatalf("consume failed: %v", err)
|
|
}
|
|
case <-consumeCtx.Done():
|
|
t.Fatalf("consumers did not finish: %v", consumeCtx.Err())
|
|
}
|
|
}
|
|
|
|
// Verify consumer A got exactly the 3 im.* events.
|
|
imLines := nonEmptyLines(imBuf.String())
|
|
if len(imLines) != 3 {
|
|
t.Fatalf("im consumer got %d events, want 3:\n%s", len(imLines), imBuf.String())
|
|
}
|
|
for i, line := range imLines {
|
|
var ev transport.Event
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
t.Fatalf("im[%d] not valid JSON: %v", i, err)
|
|
}
|
|
if !strings.HasPrefix(ev.EventType, "im.") {
|
|
t.Errorf("im consumer got non-im event: %s", ev.EventType)
|
|
}
|
|
}
|
|
|
|
// Verify consumer B got exactly the 2 approval.* events.
|
|
apprLines := nonEmptyLines(approvalBuf.String())
|
|
if len(apprLines) != 2 {
|
|
t.Fatalf("approval consumer got %d events, want 2:\n%s", len(apprLines), approvalBuf.String())
|
|
}
|
|
for i, line := range apprLines {
|
|
var ev transport.Event
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
t.Fatalf("approval[%d] not valid JSON: %v", i, err)
|
|
}
|
|
if !strings.HasPrefix(ev.EventType, "approval.") {
|
|
t.Errorf("approval consumer got non-approval event: %s", ev.EventType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIntegration_FilterRegexInAdditionToEventTypes verifies the
|
|
// regex --filter is applied on top of --event-types (logical AND).
|
|
// Both narrow the stream; the test confirms only events matching BOTH
|
|
// surface to the consumer.
|
|
func TestIntegration_FilterRegexNarrowsFurther(t *testing.T) {
|
|
skipOnWindows(t)
|
|
events := []dwsevent.RawEvent{
|
|
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
|
{EventID: "2", EventType: "im.message.at_v1", Data: `{}`},
|
|
{EventID: "3", EventType: "im.chat.member.user.added_v1", Data: `{}`},
|
|
}
|
|
dir, sock, cancel, runDone, trigger := bringUpBus(t, events)
|
|
defer func() { cancel(); <-runDone }()
|
|
|
|
var buf bytes.Buffer
|
|
ready := newConsumeReadyWriter()
|
|
consumeCtx, cancelConsumer := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancelConsumer()
|
|
consumeDone := make(chan error, 1)
|
|
go func() {
|
|
consumeDone <- Run(consumeCtx, Config{
|
|
WorkDir: dir,
|
|
IPCEndpoint: sock,
|
|
ClientID: "ding_test",
|
|
Stdout: &buf,
|
|
Stderr: ready,
|
|
EventTypes: []string{"im.*"},
|
|
Filter: `\.at_v1$`, // only at_v1 events
|
|
MaxEvents: 1,
|
|
})
|
|
}()
|
|
waitForConsumerReady(t, ready.ready)
|
|
close(trigger)
|
|
select {
|
|
case err := <-consumeDone:
|
|
if err != nil {
|
|
t.Fatalf("consume failed: %v", err)
|
|
}
|
|
case <-consumeCtx.Done():
|
|
t.Fatalf("consumer did not finish: %v", consumeCtx.Err())
|
|
}
|
|
|
|
lines := nonEmptyLines(buf.String())
|
|
if len(lines) != 1 {
|
|
t.Fatalf("expected 1 event after im.* + .at_v1 regex, got %d:\n%s", len(lines), buf.String())
|
|
}
|
|
var ev transport.Event
|
|
_ = json.Unmarshal([]byte(lines[0]), &ev)
|
|
if ev.EventType != "im.message.at_v1" {
|
|
t.Errorf("got %q, want im.message.at_v1", ev.EventType)
|
|
}
|
|
}
|
|
|
|
// TestIntegration_BusRestartConsumerReconnects verifies bus death + restart
|
|
// scenario: a consumer dialing after the first bus died and was replaced
|
|
// should connect to the fresh bus and receive new events. This proves
|
|
// stale lock cleanup + fresh-bus startup flow work correctly.
|
|
func TestIntegration_BusRestartConsumerReconnects(t *testing.T) {
|
|
skipOnWindows(t)
|
|
dir := shortTempDir(t)
|
|
sock := filepath.Join(dir, "bus.sock")
|
|
ctx1, cancel1 := context.WithCancel(context.Background())
|
|
run1Done := make(chan error, 1)
|
|
go func() {
|
|
run1Done <- bus.Run(ctx1, bus.Config{
|
|
WorkDir: dir,
|
|
IPCEndpoint: sock,
|
|
ClientID: "ding_test",
|
|
Edition: "open",
|
|
Source: &fakeSource{},
|
|
})
|
|
}()
|
|
waitForSock(t, sock, 2*time.Second)
|
|
// Verify first bus is up by dialing it briefly.
|
|
conn, err := transport.Dial(sock)
|
|
if err != nil {
|
|
t.Fatalf("dial first bus: %v", err)
|
|
}
|
|
conn.Close()
|
|
|
|
// Kill the first bus.
|
|
cancel1()
|
|
if err := <-run1Done; err != nil && !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("first bus exited with: %v", err)
|
|
}
|
|
|
|
// Start second bus on same workdir (stale lock should be reclaimed).
|
|
ctx2, cancel2 := context.WithCancel(context.Background())
|
|
defer cancel2()
|
|
run2Done := make(chan error, 1)
|
|
events := []dwsevent.RawEvent{
|
|
{EventID: "post_restart", EventType: "im.message.receive_v1", Data: `{}`},
|
|
}
|
|
trigger := make(chan struct{})
|
|
src := &fakeSource{events: events, trigger: trigger}
|
|
go func() {
|
|
run2Done <- bus.Run(ctx2, bus.Config{
|
|
WorkDir: dir,
|
|
IPCEndpoint: sock,
|
|
ClientID: "ding_test",
|
|
Edition: "open",
|
|
Source: src,
|
|
})
|
|
}()
|
|
waitForSock(t, sock, 2*time.Second)
|
|
|
|
// Consumer dials the second bus and receives the new event.
|
|
var buf bytes.Buffer
|
|
consumeDone := make(chan error, 1)
|
|
go func() {
|
|
consumeDone <- Run(context.Background(), Config{
|
|
WorkDir: dir,
|
|
IPCEndpoint: sock,
|
|
ClientID: "ding_test",
|
|
Stdout: &buf,
|
|
Stderr: io.Discard,
|
|
MaxEvents: 1,
|
|
})
|
|
}()
|
|
time.Sleep(150 * time.Millisecond)
|
|
close(trigger)
|
|
if err := <-consumeDone; err != nil {
|
|
t.Fatalf("consume on restarted bus: %v", err)
|
|
}
|
|
|
|
lines := nonEmptyLines(buf.String())
|
|
if len(lines) != 1 {
|
|
t.Fatalf("expected 1 event after restart, got %d", len(lines))
|
|
}
|
|
var ev transport.Event
|
|
_ = json.Unmarshal([]byte(lines[0]), &ev)
|
|
if ev.EventID != "post_restart" {
|
|
t.Errorf("EventID = %q, want post_restart", ev.EventID)
|
|
}
|
|
|
|
cancel2()
|
|
<-run2Done
|
|
}
|
|
|
|
// TestIntegration_PipelineWithRouteAndOutputDir is the end-to-end version
|
|
// of the unit pipeline tests: a real bus + a real consume.Run process
|
|
// configured with --route and --output-dir. Verifies that matched
|
|
// events land in route dirs and unmatched events in the fallback dir.
|
|
func TestIntegration_PipelineWithRouteAndOutputDir(t *testing.T) {
|
|
skipOnWindows(t)
|
|
events := []dwsevent.RawEvent{
|
|
{EventID: "im1", EventType: "im.message.receive_v1", Data: `{}`},
|
|
{EventID: "ap1", EventType: "approval.task", Data: `{}`},
|
|
{EventID: "im2", EventType: "im.message.at_v1", Data: `{}`},
|
|
}
|
|
dir, sock, cancel, runDone, trigger := bringUpBus(t, events)
|
|
defer func() { cancel(); <-runDone }()
|
|
|
|
outputRoot := shortTempDir(t)
|
|
imDir := filepath.Join(outputRoot, "im")
|
|
defaultDir := filepath.Join(outputRoot, "default")
|
|
routes, _ := ParseRoutes([]string{`^im\.=dir:` + imDir})
|
|
|
|
consumeDone := make(chan struct{})
|
|
go func() {
|
|
defer close(consumeDone)
|
|
_ = Run(context.Background(), Config{
|
|
WorkDir: dir,
|
|
IPCEndpoint: sock,
|
|
ClientID: "ding_test",
|
|
Stdout: io.Discard,
|
|
Stderr: io.Discard,
|
|
OutputDir: defaultDir,
|
|
Routes: routes,
|
|
MaxEvents: 3,
|
|
})
|
|
}()
|
|
time.Sleep(150 * time.Millisecond)
|
|
close(trigger)
|
|
<-consumeDone
|
|
|
|
if entries, _ := os.ReadDir(imDir); len(entries) != 2 {
|
|
t.Errorf("imDir got %d files, want 2 (im1+im2)", len(entries))
|
|
}
|
|
if entries, _ := os.ReadDir(defaultDir); len(entries) != 1 {
|
|
t.Errorf("defaultDir got %d files, want 1 (ap1)", len(entries))
|
|
}
|
|
}
|
|
|
|
func nonEmptyLines(s string) []string {
|
|
parts := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// waitForSock polls for the unix socket file. Used by bus-restart tests
|
|
// that bring up bus.Run twice in the same workdir.
|
|
func waitForSock(t *testing.T, path string, timeout time.Duration) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("socket %q did not appear within %s", path, timeout)
|
|
}
|