chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
|
||||
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
const maxEventBodyBytes = 1 << 20 // bound per-subscriber sendCh memory under runaway payloads
|
||||
|
||||
type FeishuSource struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
Domain string
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
func (s *FeishuSource) Name() string { return "feishu-websocket" }
|
||||
|
||||
func (s *FeishuSource) Start(ctx context.Context, eventTypes []string, emit func(*event.RawEvent), notify StatusNotifier) error {
|
||||
d := dispatcher.NewEventDispatcher("", "")
|
||||
|
||||
rawHandler := s.buildRawHandler(emit)
|
||||
|
||||
for _, et := range eventTypes {
|
||||
d.OnCustomizedEvent(et, rawHandler)
|
||||
}
|
||||
|
||||
opts := []larkws.ClientOption{larkws.WithEventHandler(d)}
|
||||
if s.Domain != "" {
|
||||
opts = append(opts, larkws.WithDomain(s.Domain))
|
||||
}
|
||||
if s.Logger != nil || notify != nil {
|
||||
opts = append(opts, larkws.WithLogLevel(larkcore.LogLevelInfo))
|
||||
opts = append(opts, larkws.WithLogger(&sdkLogger{l: s.Logger, notify: notify}))
|
||||
}
|
||||
|
||||
if notify != nil {
|
||||
notify(protocol.SourceStateConnecting, "")
|
||||
}
|
||||
cli := larkws.NewClient(s.AppID, s.AppSecret, opts...)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- cli.Start(ctx) }()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// buildRawHandler is extracted from Start so unit tests can exercise it without a WS client.
|
||||
func (s *FeishuSource) buildRawHandler(emit func(*event.RawEvent)) func(context.Context, *larkevent.EventReq) error {
|
||||
return func(_ context.Context, e *larkevent.EventReq) error {
|
||||
if e.Body == nil {
|
||||
return nil
|
||||
}
|
||||
if len(e.Body) > maxEventBodyBytes {
|
||||
if s.Logger != nil {
|
||||
s.Logger.Printf("[feishu] drop oversized event: %d bytes > cap %d", len(e.Body), maxEventBodyBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Body, &envelope); err != nil {
|
||||
if s.Logger != nil {
|
||||
preview := string(e.Body)
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "...(truncated)"
|
||||
}
|
||||
s.Logger.Printf("[feishu] drop malformed event: unmarshal error: %v body=%s", err, preview)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if envelope.Header.EventID == "" || envelope.Header.EventType == "" {
|
||||
if s.Logger != nil {
|
||||
s.Logger.Printf("[feishu] drop event missing header fields: event_id=%q event_type=%q",
|
||||
envelope.Header.EventID, envelope.Header.EventType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
emit(&event.RawEvent{
|
||||
EventID: envelope.Header.EventID,
|
||||
EventType: envelope.Header.EventType,
|
||||
SourceTime: envelope.Header.CreateTime,
|
||||
Payload: json.RawMessage(e.Body),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// sdkLogger forwards every SDK line to bus.log; lifecycle lines also fire notify.
|
||||
type sdkLogger struct {
|
||||
l *log.Logger
|
||||
notify StatusNotifier
|
||||
}
|
||||
|
||||
func (a *sdkLogger) Debug(_ context.Context, _ ...interface{}) {}
|
||||
func (a *sdkLogger) Info(_ context.Context, args ...interface{}) {
|
||||
msg := fmt.Sprint(args...)
|
||||
if a.l != nil {
|
||||
a.l.Output(2, "[SDK] "+msg)
|
||||
}
|
||||
a.tryNotify(msg, "")
|
||||
}
|
||||
func (a *sdkLogger) Warn(_ context.Context, args ...interface{}) {
|
||||
msg := fmt.Sprint(args...)
|
||||
if a.l != nil {
|
||||
a.l.Output(2, "[SDK WARN] "+msg)
|
||||
}
|
||||
a.tryNotify(msg, "")
|
||||
}
|
||||
func (a *sdkLogger) Error(_ context.Context, args ...interface{}) {
|
||||
msg := fmt.Sprint(args...)
|
||||
if a.l != nil {
|
||||
a.l.Output(2, "[SDK ERROR] "+msg)
|
||||
}
|
||||
// Errors usually manifest as disconnects; pass msg as detail.
|
||||
a.tryNotify(msg, msg)
|
||||
}
|
||||
|
||||
var reconnectAttemptRe = regexp.MustCompile(`reconnect:?\s*(\d+)`)
|
||||
|
||||
// tryNotify uses HasPrefix (not Contains): "connected to" matches inside "disconnected to" otherwise.
|
||||
func (a *sdkLogger) tryNotify(msg, errDetail string) {
|
||||
if a.notify == nil {
|
||||
return
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, sdkLogReconnecting):
|
||||
detail := ""
|
||||
if m := reconnectAttemptRe.FindStringSubmatch(lower); len(m) == 2 {
|
||||
detail = "attempt " + m[1]
|
||||
}
|
||||
a.notify(protocol.SourceStateReconnecting, detail)
|
||||
case strings.HasPrefix(lower, sdkLogDisconnected):
|
||||
a.notify(protocol.SourceStateDisconnected, errDetail)
|
||||
case strings.HasPrefix(lower, sdkLogConnected):
|
||||
a.notify(protocol.SourceStateConnected, "")
|
||||
}
|
||||
}
|
||||
|
||||
var _ larkcore.Logger = (*sdkLogger)(nil)
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestRawHandlerLogsMalformedJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := &FeishuSource{Logger: log.New(&buf, "", 0)}
|
||||
emitted := 0
|
||||
handler := s.buildRawHandler(func(_ *event.RawEvent) { emitted++ })
|
||||
|
||||
req := &larkevent.EventReq{Body: []byte("not-json-{{{")}
|
||||
if err := handler(context.Background(), req); err != nil {
|
||||
t.Fatalf("handler returned err: %v", err)
|
||||
}
|
||||
|
||||
if emitted != 0 {
|
||||
t.Errorf("expected 0 emits, got %d", emitted)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "malformed") {
|
||||
t.Errorf("expected log to mention 'malformed', got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "not-json") {
|
||||
t.Errorf("expected log to include body preview, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHandlerLogsMissingHeaderFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := &FeishuSource{Logger: log.New(&buf, "", 0)}
|
||||
emitted := 0
|
||||
handler := s.buildRawHandler(func(_ *event.RawEvent) { emitted++ })
|
||||
|
||||
req := &larkevent.EventReq{Body: []byte(`{"header":{"event_type":"im.receive"}}`)}
|
||||
handler(context.Background(), req)
|
||||
req2 := &larkevent.EventReq{Body: []byte(`{"header":{"event_id":"abc"}}`)}
|
||||
handler(context.Background(), req2)
|
||||
|
||||
if emitted != 0 {
|
||||
t.Errorf("expected 0 emits (both missing fields), got %d", emitted)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Count(out, "missing header fields") != 2 {
|
||||
t.Errorf("expected 2 'missing header fields' logs, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHandlerNilBodyNoLog(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := &FeishuSource{Logger: log.New(&buf, "", 0)}
|
||||
emitted := 0
|
||||
handler := s.buildRawHandler(func(_ *event.RawEvent) { emitted++ })
|
||||
|
||||
req := &larkevent.EventReq{Body: nil}
|
||||
handler(context.Background(), req)
|
||||
|
||||
if emitted != 0 {
|
||||
t.Errorf("expected 0 emits, got %d", emitted)
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("expected no log output, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHandlerValidEnvelopeEmits(t *testing.T) {
|
||||
s := &FeishuSource{}
|
||||
var captured *event.RawEvent
|
||||
handler := s.buildRawHandler(func(e *event.RawEvent) { captured = e })
|
||||
|
||||
body := []byte(`{"header":{"event_id":"evt-42","event_type":"im.message.receive_v1","create_time":"1700000000000"}}`)
|
||||
handler(context.Background(), &larkevent.EventReq{Body: body})
|
||||
|
||||
if captured == nil {
|
||||
t.Fatal("expected emit to fire")
|
||||
}
|
||||
if captured.EventID != "evt-42" {
|
||||
t.Errorf("EventID: got %q, expected evt-42", captured.EventID)
|
||||
}
|
||||
if captured.EventType != "im.message.receive_v1" {
|
||||
t.Errorf("EventType: got %q, expected im.message.receive_v1", captured.EventType)
|
||||
}
|
||||
if captured.SourceTime != "1700000000000" {
|
||||
t.Errorf("SourceTime: got %q, expected 1700000000000", captured.SourceTime)
|
||||
}
|
||||
if string(captured.Payload) != string(body) {
|
||||
t.Errorf("Payload should be raw bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHandlerNilLoggerDoesNotPanic(t *testing.T) {
|
||||
s := &FeishuSource{Logger: nil}
|
||||
handler := s.buildRawHandler(func(_ *event.RawEvent) {})
|
||||
handler(context.Background(), &larkevent.EventReq{Body: []byte("bad json")})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// "disconnected to <url>" contains "connected to ws" — must use HasPrefix to avoid misclassifying as connect.
|
||||
func TestTryNotify_Classify(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg string
|
||||
errDetail string
|
||||
wantState string
|
||||
wantDetail string
|
||||
wantCalled bool
|
||||
}{
|
||||
{
|
||||
name: "connected (SDK connect success)",
|
||||
msg: "connected to wss://example.com/gw [conn_id=abc]",
|
||||
wantState: protocol.SourceStateConnected,
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "disconnected must not be misclassified as connected",
|
||||
msg: "disconnected to wss://example.com/gw [conn_id=abc]",
|
||||
wantState: protocol.SourceStateDisconnected,
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "disconnected carries errDetail through",
|
||||
msg: "disconnected to wss://example.com/gw [conn_id=abc]",
|
||||
errDetail: "read tcp: broken pipe",
|
||||
wantState: protocol.SourceStateDisconnected,
|
||||
wantDetail: "read tcp: broken pipe",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "reconnecting with attempt 1",
|
||||
msg: "trying to reconnect: 1 [conn_id=abc]",
|
||||
wantState: protocol.SourceStateReconnecting,
|
||||
wantDetail: "attempt 1",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "reconnecting with attempt 12",
|
||||
msg: "trying to reconnect: 12",
|
||||
wantState: protocol.SourceStateReconnecting,
|
||||
wantDetail: "attempt 12",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive connected",
|
||||
msg: "CONNECTED TO WSS://example.com",
|
||||
wantState: protocol.SourceStateConnected,
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "ignore generic connect-failed error",
|
||||
msg: "connect failed, err: dial tcp: i/o timeout",
|
||||
errDetail: "connect failed, err: dial tcp: i/o timeout",
|
||||
wantCalled: false,
|
||||
},
|
||||
{
|
||||
name: "ignore read-loop failure",
|
||||
msg: "receive message failed, err: websocket: close 1006",
|
||||
errDetail: "receive message failed, err: websocket: close 1006",
|
||||
wantCalled: false,
|
||||
},
|
||||
{
|
||||
name: "ignore heartbeat noise",
|
||||
msg: "receive pong",
|
||||
wantCalled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotState, gotDetail string
|
||||
called := false
|
||||
lg := &sdkLogger{notify: func(state, detail string) {
|
||||
called = true
|
||||
gotState = state
|
||||
gotDetail = detail
|
||||
}}
|
||||
lg.tryNotify(tc.msg, tc.errDetail)
|
||||
|
||||
if called != tc.wantCalled {
|
||||
t.Fatalf("called=%v, want %v (msg=%q)", called, tc.wantCalled, tc.msg)
|
||||
}
|
||||
if !called {
|
||||
return
|
||||
}
|
||||
if gotState != tc.wantState {
|
||||
t.Errorf("state = %q, want %q", gotState, tc.wantState)
|
||||
}
|
||||
if gotDetail != tc.wantDetail {
|
||||
t.Errorf("detail = %q, want %q", gotDetail, tc.wantDetail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryNotify_NilNotifySafe(t *testing.T) {
|
||||
lg := &sdkLogger{notify: nil}
|
||||
lg.tryNotify("disconnected to wss://example.com", "")
|
||||
lg.tryNotify("connected to wss://example.com", "")
|
||||
lg.tryNotify("trying to reconnect: 1", "")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
// DO NOT trim trailing spaces — the HasPrefix disambiguator depends on them.
|
||||
const (
|
||||
sdkLogReconnecting = "trying to reconnect"
|
||||
sdkLogConnected = "connected to "
|
||||
sdkLogDisconnected = "disconnected to "
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// Samples preserve the real SDK shape ("<verb> to <url>[conn_id=...]" — no space before bracket).
|
||||
func TestSDKLogPatternsMatchKnownSDKOutput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sdkLogSample string
|
||||
expectedState string
|
||||
}{
|
||||
{
|
||||
name: "reconnect with attempt number",
|
||||
sdkLogSample: "trying to reconnect: 2[conn_id=abc123]",
|
||||
expectedState: protocol.SourceStateReconnecting,
|
||||
},
|
||||
{
|
||||
name: "reconnect high attempt",
|
||||
sdkLogSample: "trying to reconnect: 12",
|
||||
expectedState: protocol.SourceStateReconnecting,
|
||||
},
|
||||
{
|
||||
name: "connected success with conn_id",
|
||||
sdkLogSample: "connected to wss://open.feishu.cn/gateway[conn_id=abc123]",
|
||||
expectedState: protocol.SourceStateConnected,
|
||||
},
|
||||
{
|
||||
name: "connected to custom gateway",
|
||||
sdkLogSample: "connected to wss://internal.example.com/gw",
|
||||
expectedState: protocol.SourceStateConnected,
|
||||
},
|
||||
{
|
||||
name: "disconnected does not alias connected",
|
||||
sdkLogSample: "disconnected to wss://open.feishu.cn/gateway[conn_id=abc123]",
|
||||
expectedState: protocol.SourceStateDisconnected,
|
||||
},
|
||||
{
|
||||
name: "connected uppercase",
|
||||
sdkLogSample: "CONNECTED TO WSS://OPEN.FEISHU.CN/GATEWAY",
|
||||
expectedState: protocol.SourceStateConnected,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var gotState string
|
||||
called := false
|
||||
notify := func(state, detail string) {
|
||||
mu.Lock()
|
||||
gotState = state
|
||||
called = true
|
||||
mu.Unlock()
|
||||
}
|
||||
logger := &sdkLogger{notify: notify}
|
||||
logger.Info(context.Background(), tc.sdkLogSample)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !called {
|
||||
t.Fatalf("SDK log sample %q did not trigger notify — SDK log format may have changed", tc.sdkLogSample)
|
||||
}
|
||||
if gotState != tc.expectedState {
|
||||
t.Errorf("SDK log sample %q classified as %q, want %q", tc.sdkLogSample, gotState, tc.expectedState)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKLogPatternsConstantsContainExpectedSubstrings(t *testing.T) {
|
||||
if !strings.Contains(sdkLogReconnecting, "reconnect") {
|
||||
t.Errorf("sdkLogReconnecting should contain 'reconnect', got %q", sdkLogReconnecting)
|
||||
}
|
||||
if !strings.Contains(sdkLogConnected, "connected") {
|
||||
t.Errorf("sdkLogConnected should contain 'connected', got %q", sdkLogConnected)
|
||||
}
|
||||
if !strings.Contains(sdkLogDisconnected, "disconnected") {
|
||||
t.Errorf("sdkLogDisconnected should contain 'disconnected', got %q", sdkLogDisconnected)
|
||||
}
|
||||
if sdkLogReconnecting != strings.ToLower(sdkLogReconnecting) {
|
||||
t.Errorf("sdkLogReconnecting must be lowercase, got %q", sdkLogReconnecting)
|
||||
}
|
||||
if sdkLogConnected != strings.ToLower(sdkLogConnected) {
|
||||
t.Errorf("sdkLogConnected must be lowercase, got %q", sdkLogConnected)
|
||||
}
|
||||
if sdkLogDisconnected != strings.ToLower(sdkLogDisconnected) {
|
||||
t.Errorf("sdkLogDisconnected must be lowercase, got %q", sdkLogDisconnected)
|
||||
}
|
||||
if strings.HasPrefix(sdkLogDisconnected, sdkLogConnected) {
|
||||
t.Errorf("sdkLogConnected %q is a prefix of sdkLogDisconnected %q — restore the trailing space.",
|
||||
sdkLogConnected, sdkLogDisconnected)
|
||||
}
|
||||
if !strings.HasSuffix(sdkLogConnected, " ") {
|
||||
t.Error("sdkLogConnected must keep its trailing space")
|
||||
}
|
||||
if !strings.HasSuffix(sdkLogDisconnected, " ") {
|
||||
t.Error("sdkLogDisconnected must keep its trailing space")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package source is a pluggable event source abstraction (separate package to keep
|
||||
// business registrations free of SDK transitive deps).
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// StatusNotifier surfaces SourceState* lifecycle states; detail is free-form context.
|
||||
type StatusNotifier func(state, detail string)
|
||||
|
||||
// Source produces events; emit MUST return quickly (anything slow stalls the SDK read loop).
|
||||
type Source interface {
|
||||
Name() string
|
||||
Start(ctx context.Context, eventTypes []string, emit func(*event.RawEvent), notify StatusNotifier) error
|
||||
}
|
||||
|
||||
var (
|
||||
registry []Source
|
||||
registryMu sync.Mutex
|
||||
)
|
||||
|
||||
func Register(s Source) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry = append(registry, s)
|
||||
}
|
||||
|
||||
func All() []Source {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
out := make([]Source, len(registry))
|
||||
copy(out, registry)
|
||||
return out
|
||||
}
|
||||
|
||||
func ResetForTest() {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry = nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
type mockSource struct {
|
||||
name string
|
||||
events []*event.RawEvent
|
||||
}
|
||||
|
||||
func (s *mockSource) Name() string { return s.name }
|
||||
func (s *mockSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ StatusNotifier) error {
|
||||
for _, e := range s.events {
|
||||
emit(e)
|
||||
}
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
ResetForTest()
|
||||
|
||||
src := &mockSource{name: "test-source"}
|
||||
Register(src)
|
||||
|
||||
sources := All()
|
||||
if len(sources) != 1 || sources[0].Name() != "test-source" {
|
||||
t.Errorf("unexpected sources: %v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockSource_EmitsEvents(t *testing.T) {
|
||||
src := &mockSource{
|
||||
name: "test",
|
||||
events: []*event.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1"},
|
||||
{EventID: "2", EventType: "im.message.receive_v1"},
|
||||
},
|
||||
}
|
||||
|
||||
received := make(chan *event.RawEvent, 10)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
go src.Start(ctx, nil, func(e *event.RawEvent) {
|
||||
received <- e
|
||||
}, nil)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if len(received) != 2 {
|
||||
t.Errorf("expected 2 events, got %d", len(received))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user