// 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{"", "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 }