Files
dingtalk-workspace-cli/internal/event/personal/client_test.go

1006 lines
34 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 personal
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
"time"
)
func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
var gotPath string
var gotReq dwsCreateSubscriptionRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s", r.Method)
}
gotPath = r.URL.Path
if got := r.Header.Get("Authorization"); got != "Bearer token-1" {
t.Fatalf("Authorization = %q", got)
}
if got := r.Header.Get("x-user-access-token"); got != "token-1" {
t.Fatalf("x-user-access-token = %q", got)
}
if got := r.Header.Get("X-DWS-Client-Id"); got != "client-1" {
t.Fatalf("X-DWS-Client-Id = %q", got)
}
if got := r.Header.Get("X-DWS-Source-Id"); got != "open" {
t.Fatalf("X-DWS-Source-Id = %q", got)
}
if got := r.Header.Get("X-DWS-Corp-Id"); got != "corp-1" {
t.Fatalf("X-DWS-Corp-Id = %q", got)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("Decode body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": []string{"sub-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{
AccessToken: "token-1",
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
SourceID: "open",
})
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventSingleChat,
RuleType: "singleChat",
Name: "test-o2o",
RuleParam: map[string]any{
"targetUid": "test-user-001",
"targetUidType": "staffId",
},
Filter: map[string]any{"field": "payload.body.content", "op": "contains", "value": "P0"},
IdempotencyKey: "idem-1",
})
if err != nil {
t.Fatalf("CreateSubscription() error = %v", err)
}
if gotPath != "/subscription/user" {
t.Fatalf("path = %q, want /subscription/user", gotPath)
}
if gotReq.ClientID != "client-1" || gotReq.SourceID != "open" || gotReq.EventKey != EventSingleChat {
t.Fatalf("request identity/event = %#v", gotReq)
}
if gotReq.DeliveryPref != "realtime" {
t.Fatalf("deliveryPref = %q, want realtime", gotReq.DeliveryPref)
}
var filterRule map[string]any
if err := json.Unmarshal([]byte(gotReq.FilterRule), &filterRule); err != nil {
t.Fatalf("filterRule is not JSON: %q: %v", gotReq.FilterRule, err)
}
if filterRule["targetUid"] != "test-user-001" || filterRule["targetUidType"] != "staffId" {
t.Fatalf("filterRule = %#v", filterRule)
}
if gotReq.Ext["ruleType"] != "singleChat" || gotReq.Ext["name"] != "test-o2o" || gotReq.Ext["idempotencyKey"] != "idem-1" {
t.Fatalf("ext = %#v", gotReq.Ext)
}
if sub.SubscribeID != "sub-1" {
t.Fatalf("subscribe_id = %q", sub.SubscribeID)
}
if sub.EventKey != EventSingleChat || sub.RuleType != "singleChat" || sub.Status != "active" || sub.SourceID != "open" {
t.Fatalf("subscription = %#v", sub)
}
}
func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T) {
tests := []struct {
name string
eventKey string
opts RuleOptions
wantRule map[string]any
}{
{"receive_o2o/staffId", EventSingleChat, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"receive_o2o/openDingtalkId", EventSingleChat, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"read_o2o/staffId", EventReadO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"read_o2o/openDingtalkId", EventReadO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"recall_o2o/staffId", EventRecallO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"recall_o2o/openDingtalkId", EventRecallO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"reaction_o2o/staffId", EventReactionO2O, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"reaction_o2o/openDingtalkId", EventReactionO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_user/staffId", EventFromUser, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_o2o_all", EventAllSingleChat, RuleOptions{}, map[string]any{}},
{"receive_group_all", EventAllGroupChat, RuleOptions{}, map[string]any{}},
{"oa_approval_task_created", EventOAApprovalTaskCreated, RuleOptions{}, map[string]any{}},
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_cc", EventOAApprovalInstanceCC, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"group_updated", EventGroupUpdated, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"group_member_added", EventGroupMemberAdded, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"group_member_exited", EventGroupMemberExited, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"group_disbanded", EventGroupDisbanded, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotPath string
var gotReq dwsCreateSubscriptionRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Errorf("decode request: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"sub-rule"}})
}))
defer srv.Close()
ruleType, ruleParam, err := BuildRuleParam(tt.eventKey, tt.opts)
if err != nil {
t.Fatal(err)
}
client := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
if _, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: tt.eventKey,
RuleType: ruleType,
RuleParam: ruleParam,
}); err != nil {
t.Fatal(err)
}
if gotPath != "/subscription/user" || gotReq.EventKey != tt.eventKey {
t.Fatalf("path = %q, eventKey = %q", gotPath, gotReq.EventKey)
}
var gotRule map[string]any
if err := json.Unmarshal([]byte(gotReq.FilterRule), &gotRule); err != nil {
t.Fatalf("filterRule = %q: %v", gotReq.FilterRule, err)
}
if !reflect.DeepEqual(gotRule, tt.wantRule) {
t.Fatalf("filterRule = %#v, want %#v", gotRule, tt.wantRule)
}
if gotReq.Ext["ruleType"] != ruleType {
t.Fatalf("ext.ruleType = %#v, want %q", gotReq.Ext["ruleType"], ruleType)
}
})
}
}
func TestClientCreateSubscriptionObjectResponses(t *testing.T) {
cases := []map[string]any{
{"subId": "sub-camel", "eventKey": EventMention, "sourceId": "open", "status": 1},
{"subscribe_id": "sub-snake", "event_key": EventMention, "source_id": "open", "status": "active"},
}
for _, result := range cases {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": result,
})
}))
c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"})
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
srv.Close()
if err != nil {
t.Fatalf("CreateSubscription() error = %v", err)
}
if sub.SubscribeID == "" || !strings.HasPrefix(sub.SubscribeID, "sub-") {
t.Fatalf("subscription = %#v", sub)
}
}
}
func TestClientDebugLogCreateSubscriptionRequestResponse(t *testing.T) {
logs := captureClientDebugLogs(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"requestId": "req-ok",
"result": []string{"sub-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "secret-token", ClientID: "client-1", SourceID: "pre_open_source"})
if _, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventSingleChat,
RuleType: "singleChat",
RuleParam: map[string]any{
"targetUid": "test-user-001",
"targetUidType": "staffId",
},
IdempotencyKey: "idem-1",
}); err != nil {
t.Fatalf("CreateSubscription() error = %v", err)
}
out := logs.String()
for _, want := range []string{
"personal event control request",
"/subscription/user",
"client-1",
"pre_open_source",
EventSingleChat,
"filterRule",
"targetUid",
"test-user-001",
"sub-1",
"req-ok",
} {
if !strings.Contains(out, want) {
t.Fatalf("debug log missing %q: %s", want, out)
}
}
if strings.Contains(out, "secret-token") {
t.Fatalf("debug log leaked access token: %s", out)
}
}
func TestClientBusinessErrorHTTP200(t *testing.T) {
logs := captureClientDebugLogs(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"requestId": "req-1",
"errorCode": "INVALID_PARAM",
"errorMsg": "clientId is empty",
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"})
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
if err == nil || !strings.Contains(err.Error(), "INVALID_PARAM") || !strings.Contains(err.Error(), "clientId is empty") {
t.Fatalf("error = %v, want INVALID_PARAM business error", err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error type = %T, want *APIError", err)
}
if apiErr.Details["method"] != http.MethodPost || apiErr.Details["path"] != "/subscription/user" ||
apiErr.Details["http_status"] != http.StatusOK || apiErr.Details["request_id"] != "req-1" {
t.Fatalf("details = %#v", apiErr.Details)
}
if apiErr.Retryable != nil {
t.Fatalf("retryable = %v, want unknown", *apiErr.Retryable)
}
if apiErr.HTTPStatus != http.StatusOK || apiErr.TraceID != "" {
t.Fatalf("HTTP diagnostics = status %d trace %q", apiErr.HTTPStatus, apiErr.TraceID)
}
out := logs.String()
for _, want := range []string{"/subscription/user", "INVALID_PARAM", "clientId is empty", "req-1", "request", "response"} {
if !strings.Contains(out, want) {
t.Fatalf("debug log missing %q: %s", want, out)
}
}
}
func TestCrossPlatformCoverageClientBusinessErrorPreservesRetryContractAndClientHeaders(t *testing.T) {
nextRetryAt := "2026-07-30T04:05:06Z"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Cli-Version"); got != "1.2.3" {
t.Fatalf("X-Cli-Version = %q", got)
}
if got := r.Header.Get("User-Agent"); got != "dws-cli/1.2.3" {
t.Fatalf("User-Agent = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"errorCode": "RATE_LIMITED",
"errorMsg": "slow down",
"retryable": false,
"retryAfterSeconds": 30,
"nextRetryAt": nextRetryAt,
"arguments": []string{"portal-trace-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
c.ClientVersion = "1.2.3"
c.UserAgent = "dws-cli/1.2.3"
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if apiErr.Retryable == nil || *apiErr.Retryable {
t.Fatalf("retryable = %v, want explicit false", apiErr.Retryable)
}
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != 30 {
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
}
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != nextRetryAt {
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
}
if apiErr.TraceID != "portal-trace-1" || apiErr.HTTPStatus != http.StatusOK {
t.Fatalf("HTTP diagnostics = trace %q status %d", apiErr.TraceID, apiErr.HTTPStatus)
}
if _, exists := apiErr.Details["request_id"]; exists {
t.Fatalf("portal trace was mislabeled as request_id: %#v", apiErr.Details)
}
}
func TestCrossPlatformCoverageClientErrorDiagnosticIdentityPrecedence(t *testing.T) {
tests := []struct {
name string
body map[string]any
headers http.Header
wantTraceID string
wantRequestID string
}{
{
name: "nested trace wins",
body: map[string]any{
"success": false,
"requestId": "body-request",
"traceId": "top-trace",
"arguments": []string{"portal-trace"},
"error": map[string]any{
"code": "SYSTEM_ERROR",
"message": "failed",
"trace_id": "nested-trace",
},
},
headers: http.Header{
"X-Trace-Id": []string{"header-trace"},
"X-Request-Id": []string{"header-request"},
},
wantTraceID: "nested-trace",
wantRequestID: "body-request",
},
{
name: "top-level trace wins over arguments and header",
body: map[string]any{
"success": false,
"requestId": "body-request",
"traceId": "top-trace",
"arguments": []string{"portal-trace"},
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
wantTraceID: "top-trace",
wantRequestID: "body-request",
},
{
name: "portal argument wins over header",
body: map[string]any{
"success": false,
"requestId": "body-request",
"arguments": []string{"portal-trace"},
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
wantTraceID: "portal-trace",
wantRequestID: "body-request",
},
{
name: "headers are independent fallbacks",
body: map[string]any{
"success": false,
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{
"X-Trace-Id": []string{"header-trace"},
"X-Request-Id": []string{"header-request"},
},
wantTraceID: "header-trace",
wantRequestID: "header-request",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
for key, values := range test.headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
_ = json.NewEncoder(w).Encode(test.body)
}))
defer srv.Close()
client := NewClient(srv.URL, Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
})
_, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if apiErr.TraceID != test.wantTraceID {
t.Fatalf("trace_id = %q, want %q", apiErr.TraceID, test.wantTraceID)
}
if got, _ := apiErr.Details["request_id"].(string); got != test.wantRequestID {
t.Fatalf("request_id = %q, want %q; details=%#v", got, test.wantRequestID, apiErr.Details)
}
})
}
}
func TestCrossPlatformCoverageClientHTTPErrorPreservesRetryAfterAndHeaderTrace(t *testing.T) {
tests := []struct {
name string
retryAfter string
wantSeconds int64
wantNextRetryAt string
}{
{name: "delta seconds", retryAfter: "45", wantSeconds: 45},
{name: "http date", retryAfter: "Thu, 30 Jul 2026 04:05:06 GMT", wantNextRetryAt: "2026-07-30T04:05:06Z"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.Header().Set("Retry-After", tt.retryAfter)
w.Header().Set("X-Trace-Id", "header-trace")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if calls != 1 {
t.Fatalf("HTTP calls = %d, want one-shot client", calls)
}
if apiErr.Code != "HTTP_429" || apiErr.HTTPStatus != http.StatusTooManyRequests || apiErr.TraceID != "header-trace" {
t.Fatalf("API error = %#v", apiErr)
}
if apiErr.Retryable != nil {
t.Fatalf("retryable = %v, want unknown", apiErr.Retryable)
}
if tt.wantSeconds > 0 {
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != tt.wantSeconds {
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
}
}
if tt.wantNextRetryAt != "" {
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != tt.wantNextRetryAt {
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
}
}
})
}
}
func TestCrossPlatformCoverageClientErrorDiagnosticHelpersHandleNilAndMalformedInputs(t *testing.T) {
if got := withHTTPResponseDetails(nil, http.MethodPost, "/subscription/user", nil, "request-1", "trace-1"); got != nil {
t.Fatalf("withHTTPResponseDetails(nil) = %#v, want nil", got)
}
apiErr := &APIError{Code: "SYSTEM_ERROR", Message: "failed"}
got := withHTTPResponseDetails(apiErr, http.MethodPost, "/subscription/user", nil, "request-1", " trace-1 ")
if got != apiErr {
t.Fatalf("withHTTPResponseDetails() returned a different error: got %#v, want %#v", got, apiErr)
}
if got.HTTPStatus != 0 || got.TraceID != "trace-1" {
t.Fatalf("diagnostics = status %d trace %q", got.HTTPStatus, got.TraceID)
}
if got.Details["request_id"] != "request-1" {
t.Fatalf("details = %#v", got.Details)
}
if got := firstArgumentString([]json.RawMessage{json.RawMessage(`{`)}); got != "" {
t.Fatalf("firstArgumentString(malformed) = %q, want empty", got)
}
}
func TestCrossPlatformCoverageClientCreateSubscriptionRejectsErrorsWithSubscribeID(t *testing.T) {
for _, code := range []string{
"SYSTEM_ERROR",
"DUP",
"DUPLICATE_SUBSCRIPTION",
"SUBSCRIPTION_ALREADY_EXISTS",
"SUBSCRIPTION_ALREADY_EXIST",
"ALREADY_SUBSCRIBED",
"DUPLICATE",
} {
t.Run(code, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": code,
"message": "registration failed",
"details": map[string]any{"subscribe_id": "pending-sub"},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
if err == nil || sub != nil {
t.Fatalf("subscription = %#v, error = %v; API errors must stay failures", sub, err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != code {
t.Fatalf("error = %#v", err)
}
})
}
}
func TestClientOmitsCorpHeaderWhenUnknown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-DWS-Corp-Id"); got != "" {
t.Fatalf("X-DWS-Corp-Id = %q, want empty", got)
}
if got := r.Header.Get("Authorization"); got != "Bearer token-1" {
t.Fatalf("Authorization = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": []string{"sub-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"})
if _, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
}); err != nil {
t.Fatalf("CreateSubscription() error = %v", err)
}
}
func TestIdentityKeyUsesLocalSubjectFallback(t *testing.T) {
withCorpUser := Identity{CorpID: "corp-1", UserID: "user-1", ClientID: "client-1", SourceID: "open"}
if got := withCorpUser.Key(); got != "corp_user\x00corp-1\x00user-1\x00client-1\x00open" {
t.Fatalf("corp/user key = %q", got)
}
fallback := Identity{LocalSubject: "refresh:abc", ClientID: "client-1", SourceID: "open"}
if got := fallback.Key(); got != "local_subject\x00refresh:abc\x00client-1\x00open" {
t.Fatalf("fallback key = %q", got)
}
}
func TestClientDeleteSubscriptionTreatsNotFoundAsSuccess(t *testing.T) {
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s", r.Method)
}
if r.URL.Path != "/subscription/cancel" {
t.Fatalf("path = %q", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"error": map[string]any{"code": "PERSONAL_EVENT_NOT_FOUND", "message": "not found"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
if err := c.DeleteSubscription(t.Context(), "sub-404"); err != nil {
t.Fatalf("DeleteSubscription() error = %v", err)
}
if gotBody["subId"] != "sub-404" {
t.Fatalf("cancel body = %#v", gotBody)
}
}
func TestClientDeleteSubscriptionBusinessError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"requestId": "req-cancel",
"errorCode": "INVALID_STATE",
"errorMsg": "subscription cannot be cancelled",
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"})
err := c.DeleteSubscription(t.Context(), "sub-1")
if err == nil || !strings.Contains(err.Error(), "INVALID_STATE") {
t.Fatalf("DeleteSubscription() error = %v, want INVALID_STATE", err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error type = %T, want *APIError", err)
}
if apiErr.Details["path"] != "/subscription/cancel" || apiErr.Details["request_id"] != "req-cancel" {
t.Fatalf("details = %#v", apiErr.Details)
}
}
func TestClientDebugLogListAndDeleteSubscription(t *testing.T) {
logs := captureClientDebugLogs(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/event/sublist":
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{"items": []map[string]any{}},
})
case "/subscription/cancel":
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
if _, err := c.ListSubscriptions(t.Context(), ListOptions{Status: "active"}); err != nil {
t.Fatalf("ListSubscriptions() error = %v", err)
}
if err := c.DeleteSubscription(t.Context(), "sub-1"); err != nil {
t.Fatalf("DeleteSubscription() error = %v", err)
}
out := logs.String()
for _, want := range []string{"/event/sublist", "clientId=client", "sourceId=open", "/subscription/cancel", "sub-1"} {
if !strings.Contains(out, want) {
t.Fatalf("debug log missing %q: %s", want, out)
}
}
}
func TestClientDebugLogRedactsSensitivePayloadFields(t *testing.T) {
logs := captureClientDebugLogs(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"requestId": "req-secret",
"result": map[string]any{
"access_token": "resp-access-token",
"client_secret": "resp-client-secret",
"ticket": "resp-ticket",
"Authorization": "Bearer resp-auth",
"safe": "ok",
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "header-token-secret", ClientID: "client", SourceID: "open"})
err := c.do(t.Context(), http.MethodPost, "/subscription/user", nil, map[string]any{
"access_token": "req-access-token",
"client_secret": "req-client-secret",
"ticket": "req-ticket",
"Authorization": "Bearer req-auth",
"safe": "ok",
}, nil)
if err != nil {
t.Fatalf("do() error = %v", err)
}
out := logs.String()
for _, leaked := range []string{
"header-token-secret",
"req-access-token",
"req-client-secret",
"req-ticket",
"Bearer req-auth",
"resp-access-token",
"resp-client-secret",
"resp-ticket",
"Bearer resp-auth",
} {
if strings.Contains(out, leaked) {
t.Fatalf("debug log leaked %q: %s", leaked, out)
}
}
for _, want := range []string{"<redacted>", "safe", "ok", "req-secret"} {
if !strings.Contains(out, want) {
t.Fatalf("debug log missing %q: %s", want, out)
}
}
}
func TestClientListSubscriptionsDWSSublist(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/event/sublist" {
t.Fatalf("path = %q", r.URL.Path)
}
gotQuery = r.URL.RawQuery
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"total": 2,
"pageNo": 1,
"pageSize": 20,
"items": []map[string]any{
{
"subId": "sub-1",
"eventKey": EventSingleChat,
"sourceId": "open",
"deliveryPref": "realtime",
"status": 1,
"gmtCreate": "2026-06-29T10:00:00Z",
},
{
"subId": "sub-2",
"eventKey": EventMention,
"sourceId": "open",
"status": 3,
},
},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{Status: "active", EventKey: EventSingleChat})
if err != nil {
t.Fatalf("ListSubscriptions() error = %v", err)
}
if !strings.Contains(gotQuery, "clientId=client") || !strings.Contains(gotQuery, "sourceId=open") ||
!strings.Contains(gotQuery, "pageNo=1") || !strings.Contains(gotQuery, "pageSize=100") {
t.Fatalf("query = %q", gotQuery)
}
if len(subs) != 1 || subs[0].SubscribeID != "sub-1" || subs[0].Status != "active" || subs[0].CreatedAt == "" {
t.Fatalf("subs = %#v", subs)
}
}
func TestClientGetSubscriptionFiltersSublist(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"items": []map[string]any{
{"subId": "sub-1", "eventKey": EventMention, "sourceId": "open", "status": 1},
{"subId": "sub-2", "eventKey": EventSingleChat, "sourceId": "open", "status": 1},
},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
sub, err := c.GetSubscription(t.Context(), "sub-2")
if err != nil {
t.Fatalf("GetSubscription() error = %v", err)
}
if sub.SubscribeID != "sub-2" || sub.EventKey != EventSingleChat {
t.Fatalf("subscription = %#v", sub)
}
}
func TestClientListSubscriptionsPaginatesAllResults(t *testing.T) {
var pages []int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pageNo, err := strconv.Atoi(r.URL.Query().Get("pageNo"))
if err != nil {
t.Fatalf("pageNo = %q", r.URL.Query().Get("pageNo"))
}
pages = append(pages, pageNo)
start := (pageNo - 1) * subscriptionListPageSize
end := start + subscriptionListPageSize
if end > 205 {
end = 205
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"total": 205,
"items": dwsSubscriptionTestItems(start, end),
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err != nil {
t.Fatalf("ListSubscriptions() error = %v", err)
}
if len(subs) != 205 || subs[204].SubscribeID != "sub-204" {
t.Fatalf("subscriptions = %d, last = %#v", len(subs), subs[len(subs)-1])
}
if fmt.Sprint(pages) != "[1 2 3]" {
t.Fatalf("pages = %v, want [1 2 3]", pages)
}
}
func TestClientGetSubscriptionFindsLaterPage(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
pageNo, _ := strconv.Atoi(r.URL.Query().Get("pageNo"))
items := dwsSubscriptionTestItems(0, 100)
if pageNo == 2 {
items = dwsSubscriptionTestItems(100, 101)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{"total": 101, "items": items},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
sub, err := c.GetSubscription(t.Context(), "sub-100")
if err != nil {
t.Fatalf("GetSubscription() error = %v", err)
}
if sub.SubscribeID != "sub-100" || calls != 2 {
t.Fatalf("subscription = %#v, calls = %d", sub, calls)
}
}
func TestClientListSubscriptionsWithoutTotalStopsOnEmptyPage(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
items := dwsSubscriptionTestItems(0, 100)
if r.URL.Query().Get("pageNo") == "2" {
items = nil
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{"items": items},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err != nil {
t.Fatal(err)
}
if len(subs) != 100 || calls != 2 {
t.Fatalf("subscriptions = %d, calls = %d", len(subs), calls)
}
}
func TestClientListSubscriptionsUsesServerPageSize(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
pageNo, _ := strconv.Atoi(r.URL.Query().Get("pageNo"))
start := (pageNo - 1) * 20
end := start + 20
if end > 45 {
end = 45
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"total": 45,
"pageSize": 20,
"items": dwsSubscriptionTestItems(start, end),
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err != nil {
t.Fatal(err)
}
if len(subs) != 45 || calls != 3 {
t.Fatalf("subscriptions = %d, calls = %d", len(subs), calls)
}
}
func TestClientListSubscriptionsDeduplicatesSubscribeID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
items := dwsSubscriptionTestItems(0, 100)
if r.URL.Query().Get("pageNo") == "2" {
items = append(dwsSubscriptionTestItems(99, 100), dwsSubscriptionTestItems(100, 101)...)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{"total": 101, "items": items},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
subs, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err != nil {
t.Fatal(err)
}
if len(subs) != 101 || subs[100].SubscribeID != "sub-100" {
t.Fatalf("subscriptions = %#v", subs)
}
}
func TestClientListSubscriptionsRejectsRepeatedPage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"total": 200,
"items": dwsSubscriptionTestItems(0, 100),
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
_, err := c.ListSubscriptions(t.Context(), ListOptions{})
if err == nil || !strings.Contains(err.Error(), "pagination made no progress") {
t.Fatalf("ListSubscriptions() error = %v", err)
}
}
func dwsSubscriptionTestItems(start, end int) []map[string]any {
items := make([]map[string]any, 0, end-start)
for i := start; i < end; i++ {
items = append(items, map[string]any{
"subId": fmt.Sprintf("sub-%d", i),
"eventKey": EventSingleChat,
"sourceId": "open",
"status": 1,
})
}
return items
}
func captureClientDebugLogs(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
}