Files
dingtalk-workspace-cli/internal/apiclient/client_test.go

327 lines
9.4 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 apiclient
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestNewClient_DefaultBaseURL(t *testing.T) {
c := NewClient("tok", "")
if c.BaseURL != DefaultBaseURL {
t.Errorf("expected %q, got %q", DefaultBaseURL, c.BaseURL)
}
}
func TestNewClient_CustomBaseURL(t *testing.T) {
c := NewClient("tok", "https://custom.api.com/")
if c.BaseURL != "https://custom.api.com" {
t.Errorf("expected trailing slash stripped, got %q", c.BaseURL)
}
}
func TestNormalisePath(t *testing.T) {
tests := []struct {
path, base, want string
}{
{"/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
{"v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
{"https://api.dingtalk.com/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
{"/v1.0/users?foo=bar#frag", "", "https://api.dingtalk.com/v1.0/users"},
{"/v1.0/users", "https://custom.example.com", "https://custom.example.com/v1.0/users"},
}
for _, tt := range tests {
got := NormalisePath(tt.path, tt.base)
if got != tt.want {
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
}
}
}
func TestDo_Success(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(AuthHeader) != "test-token" {
t.Errorf("expected auth header %q, got %q", "test-token", r.Header.Get(AuthHeader))
}
if r.Method != "GET" {
t.Errorf("expected GET, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]string{"name": "test"})
}))
defer srv.Close()
c := NewClient("test-token", srv.URL)
resp, err := c.Do(context.Background(), RawAPIRequest{
Method: "GET",
Path: "/v1.0/test",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestDo_PostWithBody(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("expected POST, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected JSON content type")
}
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["key"] != "value" {
t.Errorf("expected body key=value, got %v", body)
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
resp, err := c.Do(context.Background(), RawAPIRequest{
Method: "POST",
Path: "/v1.0/test",
Data: map[string]string{"key": "value"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestDo_InvalidMethod(t *testing.T) {
c := NewClient("tok", "")
_, err := c.Do(context.Background(), RawAPIRequest{
Method: "INVALID",
Path: "/test",
})
if err == nil {
t.Error("expected error for invalid method")
}
}
func TestDo_QueryParams(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("pageSize") != "10" {
t.Errorf("expected pageSize=10, got %v", r.URL.Query())
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL)
_, err := c.Do(context.Background(), RawAPIRequest{
Method: "GET",
Path: "/v1.0/test",
Params: map[string]any{"pageSize": 10},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIsLegacyAPI(t *testing.T) {
tests := []struct {
url string
want bool
}{
{"https://api.dingtalk.com/v1.0/users", false},
{"https://oapi.dingtalk.com/topapi/v2/user/get", true},
{"https://OAPI.DINGTALK.COM/topapi/v2/user/get", true},
{"https://custom.example.com/api", false},
{"", false},
}
for _, tt := range tests {
got := IsLegacyAPI(tt.url)
if got != tt.want {
t.Errorf("IsLegacyAPI(%q) = %v, want %v", tt.url, got, tt.want)
}
}
}
func TestDo_LegacyAPI_TokenInQueryParam(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Legacy API: token should be in query param.
if r.URL.Query().Get(LegacyAuthParam) != "legacy-token" {
t.Errorf("expected access_token=legacy-token in query, got %v", r.URL.Query())
}
// Should NOT have the new-style auth header.
if r.Header.Get(AuthHeader) != "" {
t.Errorf("expected no auth header for legacy API, got %q", r.Header.Get(AuthHeader))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
w.Write([]byte(`{"errcode":0,"errmsg":"ok","result":{"userid":"user1"}}`))
}))
defer srv.Close()
// Use full URL with oapi.dingtalk.com in the path, but redirect to test server.
// Since we can't DNS-resolve oapi.dingtalk.com, we use the test server URL
// and pass the full oapi URL as Path so that NormalisePath preserves it.
// Then we override the resolved URL in the client to point to our test server.
//
// Best approach: directly verify that buildURL + IsLegacyAPI routing works
// by testing buildURL output and calling Do with a custom transport that
// redirects oapi.dingtalk.com to our test server.
c := NewClient("legacy-token", "")
// Replace the transport to redirect oapi.dingtalk.com to test server.
c.HTTPClient.Transport = &legacyTestTransport{targetURL: srv.URL}
resp, err := c.Do(context.Background(), RawAPIRequest{
Method: "POST",
Path: "https://oapi.dingtalk.com/topapi/v2/user/get",
Data: map[string]string{"userid": "user1"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
// legacyTestTransport redirects requests from oapi.dingtalk.com to a local test server.
type legacyTestTransport struct {
targetURL string
}
func (t *legacyTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Rewrite the host to point to our test server, preserving path and query.
newURL := t.targetURL + req.URL.Path
if req.URL.RawQuery != "" {
newURL += "?" + req.URL.RawQuery
}
parsed, _ := url.Parse(newURL)
req.URL = parsed
req.Host = parsed.Host
return http.DefaultTransport.RoundTrip(req)
}
func TestNormalisePath_Legacy(t *testing.T) {
tests := []struct {
path, base, want string
}{
// Legacy full URL preserved.
{"https://oapi.dingtalk.com/topapi/v2/user/get", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
// Relative path with legacy base URL.
{"/topapi/v2/user/get", LegacyBaseURL, "https://oapi.dingtalk.com/topapi/v2/user/get"},
// Strip query from legacy URL.
{"https://oapi.dingtalk.com/topapi/v2/user/get?access_token=xxx", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
}
for _, tt := range tests {
got := NormalisePath(tt.path, tt.base)
if got != tt.want {
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
}
}
}
func TestResolvePageLimit(t *testing.T) {
t.Parallel()
tests := []struct {
raw, want int
}{
// 0 → unlimited → safety cap
{0, MaxPageLimit},
// normal usage
{3, 3},
// default
{10, 10},
// within cap
{100, 100},
// exactly cap
{MaxPageLimit, MaxPageLimit},
// exceeds cap
{MaxPageLimit + 100, MaxPageLimit},
// negative → default
{-1, DefaultPageLimit},
{-100, DefaultPageLimit},
}
for _, tt := range tests {
got := resolvePageLimit(tt.raw)
if got != tt.want {
t.Errorf("resolvePageLimit(%d) = %d, want %d", tt.raw, got, tt.want)
}
}
}
func TestPaginateAll_ProgressLog(t *testing.T) {
AllowedHosts["127.0.0.1"] = true
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount >= 3 {
json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{"has_more": false, "items": []any{1, 2}},
})
} else {
json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{
"has_more": true,
"next_cursor": 100,
"items": []any{callCount},
},
})
}
}))
defer srv.Close()
c := NewClient("test-token", srv.URL)
var logBuf bytes.Buffer
pages, err := c.PaginateAll(context.Background(), RawAPIRequest{
Method: "GET",
Path: "/v1.0/test",
}, PaginationOptions{
PageLimit: 5,
PageDelay: 0,
LogWriter: &logBuf,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pages) != 3 {
t.Errorf("expected 3 pages, got %d", len(pages))
}
log := logBuf.String()
if !strings.Contains(log, "第 1 页") || !strings.Contains(log, "第 2 页") || !strings.Contains(log, "第 3 页") {
t.Errorf("expected progress log for each page, got: %s", log)
}
if !strings.Contains(log, "数据获取完成") {
t.Errorf("expected completion message, got: %s", log)
}
}