Files
dingtalk-workspace-cli/internal/event/source/personal_test.go

776 lines
22 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 source
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"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"
"github.com/gorilla/websocket"
streamevent "github.com/open-dingtalk/dingtalk-stream-sdk-go/event"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
)
func TestPersonalSourceFetchTicketConnectsAndACKs(t *testing.T) {
logs := capturePersonalSourceDebugLogs(t)
var wsEndpoint string
ackCh := make(chan payload.DataFrameResponse, 1)
upgrader := websocket.Upgrader{}
mux := http.NewServeMux()
mux.HandleFunc("/ticket", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("x-user-access-token"); got != "token-1" {
t.Fatalf("x-user-access-token = %q", got)
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode ticket request: %v", err)
}
if req["sourceId"] != "open" || req["mode"] != "normal" {
t.Fatalf("ticket request = %#v", req)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"endpoint": wsEndpoint,
"ticket": "ticket-1",
},
})
})
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("ticket"); got != "ticket-1" {
t.Fatalf("ticket query = %q", got)
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Fatalf("upgrade: %v", err)
}
defer conn.Close()
df := payload.DataFrame{
Type: "event",
Headers: payload.DataFrameHeader{
payload.DataFrameHeaderKMessageId: "msg-1",
streamevent.DataFrameHeaderKEventId: "evt-1",
streamevent.DataFrameHeaderKEventBornTime: "1234",
streamevent.DataFrameHeaderKEventCorpId: "corp-1",
streamevent.DataFrameHeaderKEventType: "user_im_message_receive_at",
"subscribeId": "sub-1",
"ruleType": "at",
"sourceId": "open",
"accessToken": "header-secret-token",
},
Data: `{"message":{"text":"hi"},"access_token":"data-secret-token","client_secret":"data-secret","ticket":"data-ticket","Authorization":"Bearer data-auth"}`,
}
if err := conn.WriteJSON(df); err != nil {
t.Fatalf("write dataframe: %v", err)
}
var ack payload.DataFrameResponse
if err := conn.ReadJSON(&ack); err != nil {
t.Fatalf("read ack: %v", err)
}
ackCh <- ack
})
srv := httptest.NewServer(mux)
defer srv.Close()
wsEndpoint = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
src, err := NewPersonal(PersonalConfig{
AccessToken: "token-1",
ClientID: "client-1",
SourceID: "open",
TicketURL: srv.URL + "/ticket",
TicketMode: "normal",
HTTPClient: srv.Client(),
Now: func() time.Time { return time.Unix(10, 0) },
})
if err != nil {
t.Fatalf("NewPersonal() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
evCh := make(chan *dwsevent.RawEvent, 1)
done := make(chan error, 1)
go func() {
done <- src.Start(ctx, func(ev *dwsevent.RawEvent) { evCh <- ev })
}()
select {
case ev := <-evCh:
if ev.SubscribeID != "sub-1" || ev.RuleType != "at" || ev.SourceID != "open" {
t.Fatalf("event personal fields = %#v", ev)
}
if ev.EventType != "user_im_message_receive_at" || ev.EventID != "evt-1" {
t.Fatalf("event = %#v", ev)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for event")
}
select {
case ack := <-ackCh:
if ack.Code != payload.DataFrameResponseStatusCodeKOK {
t.Fatalf("ack code = %d", ack.Code)
}
if ack.GetHeader(payload.DataFrameHeaderKMessageId) != "msg-1" {
t.Fatalf("ack message id = %q", ack.GetHeader(payload.DataFrameHeaderKMessageId))
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for ack")
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("source did not stop after cancel")
}
// Start can log a reconnect after emitting the event and before observing
// cancellation. Read the shared log buffer only after the goroutine exits so
// the assertion remains race-free under `go test -race`.
out := logs.String()
for _, want := range []string{"personal source received dataframe", "user_im_message_receive_at", "evt-1", "sub-1", "sourceId", "message", "<redacted>"} {
if !strings.Contains(out, want) {
t.Fatalf("debug log missing %q: %s", want, out)
}
}
for _, leaked := range []string{"header-secret-token", "data-secret-token", "data-secret", "data-ticket", "Bearer data-auth"} {
if strings.Contains(out, leaked) {
t.Fatalf("debug log leaked %q: %s", leaked, out)
}
}
}
func TestPersonalSourceReconnectsWithFreshTicket(t *testing.T) {
var wsEndpoint string
var ticketCalls atomic.Int32
ackCh := make(chan int, 2)
holdSecond := make(chan struct{})
var releaseSecondOnce sync.Once
releaseSecond := func() { releaseSecondOnce.Do(func() { close(holdSecond) }) }
upgrader := websocket.Upgrader{}
mux := http.NewServeMux()
mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) {
attempt := int(ticketCalls.Add(1))
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"endpoint": wsEndpoint,
"ticket": fmt.Sprintf("ticket-%d", attempt),
},
})
})
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
attempt, err := strconv.Atoi(strings.TrimPrefix(r.URL.Query().Get("ticket"), "ticket-"))
if err != nil {
http.Error(w, "bad ticket", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if err := conn.WriteJSON(personalTestDataFrame(attempt)); err != nil {
return
}
var ack payload.DataFrameResponse
if err := conn.ReadJSON(&ack); err != nil {
return
}
ackCh <- attempt
if attempt == 2 {
<-holdSecond
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
defer releaseSecond()
wsEndpoint = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
src, err := NewPersonal(PersonalConfig{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
TicketURL: srv.URL + "/ticket",
HTTPClient: srv.Client(),
ReconnectMin: 5 * time.Millisecond,
ReconnectMax: 10 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events := make(chan *dwsevent.RawEvent, 2)
done := make(chan error, 1)
go func() { done <- src.Start(ctx, func(ev *dwsevent.RawEvent) { events <- ev }) }()
for i := 1; i <= 2; i++ {
select {
case ev := <-events:
if ev.EventID != fmt.Sprintf("evt-%d", i) {
t.Fatalf("event %d ID = %q", i, ev.EventID)
}
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for event %d", i)
}
select {
case attempt := <-ackCh:
if attempt != i {
t.Fatalf("ack attempt = %d, want %d", attempt, i)
}
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for ACK %d", i)
}
}
if got := ticketCalls.Load(); got != 2 {
t.Fatalf("ticket calls = %d, want 2", got)
}
if got := src.State().ReconnectCount; got != 1 {
t.Fatalf("reconnect count = %d, want 1", got)
}
cancel()
releaseSecond()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Start() error = %v, want context canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("source did not stop after cancel")
}
}
func TestPersonalSourceRetriesTicket500And429(t *testing.T) {
var wsEndpoint string
var ticketCalls atomic.Int32
acked := make(chan struct{}, 1)
hold := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(hold) }) }
upgrader := websocket.Upgrader{}
mux := http.NewServeMux()
mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) {
switch ticketCalls.Add(1) {
case 1:
w.WriteHeader(http.StatusInternalServerError)
return
case 2:
w.WriteHeader(http.StatusTooManyRequests)
return
default:
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{"endpoint": wsEndpoint, "ticket": "ticket-ok"},
})
}
})
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if err := conn.WriteJSON(personalTestDataFrame(1)); err != nil {
return
}
var ack payload.DataFrameResponse
if err := conn.ReadJSON(&ack); err != nil {
return
}
acked <- struct{}{}
<-hold
})
srv := httptest.NewServer(mux)
defer srv.Close()
defer release()
wsEndpoint = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
src, err := NewPersonal(PersonalConfig{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
TicketURL: srv.URL + "/ticket",
HTTPClient: srv.Client(),
ReconnectMin: time.Millisecond,
ReconnectMax: 2 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- src.Start(ctx, func(*dwsevent.RawEvent) {}) }()
select {
case <-acked:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for recovered connection")
}
if got := ticketCalls.Load(); got != 3 {
t.Fatalf("ticket calls = %d, want 3", got)
}
if got := src.State().ReconnectCount; got != 2 {
t.Fatalf("reconnect count = %d, want 2", got)
}
cancel()
release()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("source did not stop after cancel")
}
}
func TestPersonalSourceTicketFatalResponsesDoNotRetry(t *testing.T) {
tests := []struct {
name string
status int
body string
}{
{name: "unauthorized", status: http.StatusUnauthorized},
{name: "forbidden", status: http.StatusForbidden},
{name: "malformed success", status: http.StatusOK, body: `{"success":true,"result":{"endpoint":"","ticket":""}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer srv.Close()
src, err := NewPersonal(PersonalConfig{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
TicketURL: srv.URL,
HTTPClient: srv.Client(),
ReconnectMin: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
if err := src.Start(t.Context(), func(*dwsevent.RawEvent) {}); err == nil {
t.Fatal("Start() error = nil, want fatal ticket error")
}
if got := calls.Load(); got != 1 {
t.Fatalf("ticket calls = %d, want 1", got)
}
})
}
}
func TestPersonalSourceRetriesTicketNetworkError(t *testing.T) {
var calls atomic.Int32
secondCall := make(chan struct{}, 1)
httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
if calls.Add(1) == 2 {
secondCall <- struct{}{}
}
return nil, errors.New("network unavailable")
})}
src, err := NewPersonal(PersonalConfig{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
TicketURL: "https://ticket.invalid",
HTTPClient: httpClient,
ReconnectMin: time.Millisecond,
ReconnectMax: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- src.Start(ctx, func(*dwsevent.RawEvent) {}) }()
select {
case <-secondCall:
case <-time.After(time.Second):
t.Fatal("ticket network error was not retried")
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Start() error = %v, want context canceled", err)
}
case <-time.After(time.Second):
t.Fatal("source did not stop after cancel")
}
}
func TestPersonalSourceCancelDuringReconnectBackoff(t *testing.T) {
requestDone := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestDone <- struct{}{}
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
src, err := NewPersonal(PersonalConfig{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
TicketURL: srv.URL,
HTTPClient: srv.Client(),
ReconnectMin: 5 * time.Second,
ReconnectMax: 5 * time.Second,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- src.Start(ctx, func(*dwsevent.RawEvent) {}) }()
select {
case <-requestDone:
case <-time.After(time.Second):
t.Fatal("ticket request did not arrive")
}
deadline := time.Now().Add(time.Second)
for src.State().ReconnectCount == 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if src.State().ReconnectCount == 0 {
t.Fatal("source did not enter reconnect backoff")
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Start() error = %v, want context canceled", err)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("source did not exit promptly during reconnect backoff")
}
}
func TestNextPersonalBackoffCapsAtMaximum(t *testing.T) {
if got := nextPersonalBackoff(time.Second, 30*time.Second); got != 2*time.Second {
t.Fatalf("next backoff = %s, want 2s", got)
}
if got := nextPersonalBackoff(20*time.Second, 30*time.Second); got != 30*time.Second {
t.Fatalf("capped backoff = %s, want 30s", got)
}
}
func TestPersonalRetryLogErrorDoesNotExposeWebSocketURL(t *testing.T) {
err := retryPersonal(errors.New("personal source: dial websocket: wss://example.test/ws?ticket=secret-ticket"))
got := personalRetryLogError(err)
if strings.Contains(got, "secret-ticket") || strings.Contains(got, "example.test") {
t.Fatalf("retry log exposed websocket details: %q", got)
}
}
func TestPersonalSourceParsesUppercaseHeaders(t *testing.T) {
src := personalSourceForRawEventTests()
data := `{"payload":true}`
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Time: 12345,
Headers: payload.DataFrameHeader{
"EVENT_TYPE": "user_im_message_receive_o2o",
"SUB_ID": "sub-1",
"SOURCE_ID": "pre_open_source",
"MESSAGE_ID": "evt-1",
},
Data: data,
})
if raw.EventType != "user_im_message_receive_o2o" {
t.Fatalf("EventType = %q", raw.EventType)
}
if raw.SubscribeID != "sub-1" {
t.Fatalf("SubscribeID = %q", raw.SubscribeID)
}
if raw.SourceID != "pre_open_source" {
t.Fatalf("SourceID = %q", raw.SourceID)
}
if raw.EventID != "evt-1" {
t.Fatalf("EventID = %q", raw.EventID)
}
if raw.EventScope != "personal" {
t.Fatalf("EventScope = %q", raw.EventScope)
}
if raw.Data != data {
t.Fatalf("Data changed: %q", raw.Data)
}
}
func TestPersonalSourceParsesTopicFallbackAndIgnoresWildcard(t *testing.T) {
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"TOPIC": "user_im_message_receive_o2o",
"topic": "*",
},
})
if raw.EventType != "user_im_message_receive_o2o" {
t.Fatalf("EventType from TOPIC = %q", raw.EventType)
}
raw = src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{"topic": "*"},
})
if raw.EventType != "" {
t.Fatalf("EventType from wildcard topic = %q, want empty", raw.EventType)
}
}
func TestPersonalSourceParsesDataFallbackWithoutChangingData(t *testing.T) {
src := personalSourceForRawEventTests()
payloadJSON := `{"eventKey":"user_im_message_receive_o2o","subId":"sub-data","eventId":"evt-data","ext":{"ruleType":"singleChat"}}`
encodedPayload, err := json.Marshal(payloadJSON)
if err != nil {
t.Fatal(err)
}
data := string(encodedPayload)
raw := src.rawEventFromDataFrame(&payload.DataFrame{Data: data})
if raw.EventType != "user_im_message_receive_o2o" {
t.Fatalf("EventType = %q", raw.EventType)
}
if raw.SubscribeID != "sub-data" {
t.Fatalf("SubscribeID = %q", raw.SubscribeID)
}
if raw.EventID != "evt-data" {
t.Fatalf("EventID = %q", raw.EventID)
}
if raw.RuleType != "singleChat" {
t.Fatalf("RuleType = %q", raw.RuleType)
}
if raw.Data != data {
t.Fatalf("Data changed: %q", raw.Data)
}
}
func TestPersonalSourceParsesSourceTagDataFallback(t *testing.T) {
src := personalSourceForRawEventTests()
data := `{"source":{"tag":"user_im_message_receive_group"},"subId":"sub-group"}`
raw := src.rawEventFromDataFrame(&payload.DataFrame{Data: data})
if raw.EventType != "user_im_message_receive_group" {
t.Fatalf("EventType = %q", raw.EventType)
}
if raw.SubscribeID != "sub-group" {
t.Fatalf("SubscribeID = %q", raw.SubscribeID)
}
}
func TestPersonalSourceParsedHeadersPassNormalBusFilter(t *testing.T) {
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"EVENT_TYPE": "user_im_message_receive_o2o",
"SUB_ID": "sub-1",
},
})
h := bus.NewHub(10)
c, err := h.Register(transport.Hello{
EventTypes: []string{"user_im_message_receive_o2o"},
SubscribeID: "sub-1",
})
if err != nil {
t.Fatal(err)
}
h.Deliver(raw)
select {
case frame := <-c.SendCh:
ev, ok := frame.(transport.Event)
if !ok {
t.Fatalf("frame = %T, want transport.Event", frame)
}
if ev.EventType != "user_im_message_receive_o2o" || ev.SubscribeID != "sub-1" {
t.Fatalf("event = %#v", ev)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for filtered event")
}
}
func TestPersonalSourceActionEventPassesNormalBusFilter(t *testing.T) {
const (
eventKey = "user_im_message_reaction_group"
subscribeID = "sub-reaction-group"
)
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"EVENT_TYPE": eventKey,
"SUB_ID": subscribeID,
},
Data: `{"eventKey":"user_im_message_reaction_group","subId":"sub-reaction-group","payload":{}}`,
})
h := bus.NewHub(10)
consumer, err := h.Register(transport.Hello{
EventTypes: []string{eventKey},
SubscribeID: subscribeID,
})
if err != nil {
t.Fatal(err)
}
h.Deliver(raw)
select {
case frame := <-consumer.SendCh:
eventFrame, ok := frame.(transport.Event)
if !ok {
t.Fatalf("frame = %T, want transport.Event", frame)
}
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
t.Fatalf("event = %#v", eventFrame)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for action event")
}
}
func TestPersonalSourceSenderEventPassesNormalBusFilter(t *testing.T) {
const (
eventKey = "user_im_message_receive_user"
subscribeID = "sub-receive-user"
)
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"EVENT_TYPE": eventKey,
"SUB_ID": subscribeID,
},
Data: `{"eventKey":"user_im_message_receive_user","subId":"sub-receive-user","payload":{}}`,
})
h := bus.NewHub(10)
consumer, err := h.Register(transport.Hello{
EventTypes: []string{eventKey},
SubscribeID: subscribeID,
})
if err != nil {
t.Fatal(err)
}
h.Deliver(raw)
select {
case frame := <-consumer.SendCh:
eventFrame, ok := frame.(transport.Event)
if !ok {
t.Fatalf("frame = %T, want transport.Event", frame)
}
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
t.Fatalf("event = %#v", eventFrame)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for sender event")
}
}
func TestPersonalSourceNewIMEventsPassNormalBusFilter(t *testing.T) {
for _, eventKey := range []string{
"user_im_message_receive_o2o_all",
"user_im_message_receive_group_all",
"user_im_group_updated",
"user_im_group_member_added",
"user_im_group_member_exited",
"user_im_group_disbanded",
} {
t.Run(eventKey, func(t *testing.T) {
subscribeID := "sub-" + eventKey
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"EVENT_TYPE": eventKey,
"SUB_ID": subscribeID,
},
Data: fmt.Sprintf(`{"eventKey":%q,"subId":%q,"payload":{"body":{"content":"test"}}}`, eventKey, subscribeID),
})
h := bus.NewHub(10)
consumer, err := h.Register(transport.Hello{
EventTypes: []string{eventKey},
SubscribeID: subscribeID,
})
if err != nil {
t.Fatal(err)
}
h.Deliver(raw)
select {
case frame := <-consumer.SendCh:
eventFrame, ok := frame.(transport.Event)
if !ok {
t.Fatalf("frame = %T, want transport.Event", frame)
}
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
t.Fatalf("event = %#v", eventFrame)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for filtered event")
}
})
}
}
func personalSourceForRawEventTests() *PersonalSource {
return &PersonalSource{cfg: PersonalConfig{
SourceID: "fallback_source",
Now: func() time.Time { return time.Unix(20, 0) },
}}
}
func personalTestDataFrame(attempt int) payload.DataFrame {
return payload.DataFrame{
Type: "event",
Headers: payload.DataFrameHeader{
payload.DataFrameHeaderKMessageId: fmt.Sprintf("msg-%d", attempt),
streamevent.DataFrameHeaderKEventId: fmt.Sprintf("evt-%d", attempt),
streamevent.DataFrameHeaderKEventType: "user_im_message_receive_o2o",
"SUB_ID": fmt.Sprintf("sub-%d", attempt),
},
Data: fmt.Sprintf(`{"eventKey":"user_im_message_receive_o2o","eventId":"evt-%d"}`, attempt),
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func capturePersonalSourceDebugLogs(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() {
slog.SetDefault(previous)
})
return &buf
}