327 lines
9.4 KiB
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)
|
|
}
|
|
}
|