Files
dobotai-platform/internal/questiontable/client_test.go
2026-07-23 18:55:03 +08:00

578 lines
19 KiB
Go

package questiontable
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestNewClientRejectsMissingConfig(t *testing.T) {
tests := []struct {
name string
cfg Config
}{
{"empty client id", Config{Secret: "s", BaseID: "b", SheetID: "sh", OperatorID: "op"}},
{"empty secret", Config{ClientID: "c", BaseID: "b", SheetID: "sh", OperatorID: "op"}},
{"empty base id", Config{ClientID: "c", Secret: "s", SheetID: "sh", OperatorID: "op"}},
{"empty sheet id", Config{ClientID: "c", Secret: "s", BaseID: "b", OperatorID: "op"}},
{"empty operator id", Config{ClientID: "c", Secret: "s", BaseID: "b", SheetID: "sh"}},
}
for _, tt := range tests {
_, err := NewClient(tt.cfg, nil)
if err == nil {
t.Errorf("%s: expected error, got nil", tt.name)
}
}
}
func TestInsertRecordMethodPathHeaderBody(t *testing.T) {
var (
gotMethod string
gotPath string
gotQuery string
gotToken string
gotBody string
tokenCalls int32
insertCalls int32
)
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&tokenCalls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"test-access-token","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/b1/sheets/sh1/records", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&insertCalls, 1)
gotMethod = r.Method
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
gotToken = r.Header.Get("x-acs-dingtalk-access-token")
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode body: %v", err)
}
records, ok := body["records"].([]interface{})
if !ok || len(records) != 1 {
t.Errorf("records must contain exactly one record: %#v", body["records"])
} else {
record, ok := records[0].(map[string]interface{})
if !ok {
t.Errorf("record must be an object: %#v", records[0])
} else {
fields, ok := record["fields"].(map[string]interface{})
if !ok {
t.Errorf("record must wrap values in a fields object: %#v", record)
} else if len(record) != 1 {
t.Errorf("record must contain only fields, got keys: %#v", record)
} else if _, ok := fields[FieldOriginalQuestion]; !ok {
t.Errorf("nested fields missing original question field")
}
}
}
raw, _ := json.Marshal(body)
gotBody = string(raw)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"value":[{"id":"rec-abc-123"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, err := NewClient(Config{
ClientID: "appKey-1",
Secret: "appSecret-1",
BaseID: "b1",
SheetID: "sh1",
OperatorID: "operator-1",
TokenURL: server.URL + "/v1.0/oauth2/accessToken",
BaseURL: server.URL,
}, nil)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
row := Row{
Question: "测试问题",
Answer: "测试答案",
AskerName: "张三",
AskerUnionID: "union-zhang-san",
}
recID, err := client.InsertRecord(context.Background(), row)
if err != nil {
t.Fatalf("InsertRecord: %v", err)
}
if recID != "rec-abc-123" {
t.Errorf("recordID = %q, want %q", recID, "rec-abc-123")
}
// Assert method, path, header, token.
if gotMethod != http.MethodPost {
t.Errorf("method = %q, want POST", gotMethod)
}
if gotPath != "/v1.0/notable/bases/b1/sheets/sh1/records" {
t.Errorf("path = %q", gotPath)
}
if gotToken != "test-access-token" {
t.Errorf("token header = %q, want test-access-token", gotToken)
}
// operatorId must be in query only.
if !strings.Contains(gotQuery, "operatorId=operator-1") {
t.Errorf("query missing operatorId: %q", gotQuery)
}
// body must NOT contain operatorId.
if strings.Contains(gotBody, "operatorId") {
t.Errorf("body contains operatorId: %q", gotBody)
}
// body must contain typed field IDs.
if !strings.Contains(gotBody, `"ZaMZF6j"`) {
t.Errorf("body missing field ID ZaMZF6j: %q", gotBody)
}
if !strings.Contains(gotBody, `"Y4cpmxU"`) {
t.Errorf("body missing field ID Y4cpmxU: %q", gotBody)
}
// body must NOT contain the omitted select fields.
omitted := []string{"QMn9UoU", "H3gtTuv", "nFvv8OR", "UMqUPAG", "lnF7gF9"}
for _, fid := range omitted {
if strings.Contains(gotBody, fid) {
t.Errorf("body contains omitted select field %q: %q", fid, gotBody)
}
}
}
func TestTokenCacheTwoInsertsOneTokenCall(t *testing.T) {
var tokenCalls int32
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&tokenCalls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"cached-token","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/b1/sheets/sh1/records", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-acs-dingtalk-access-token") != "cached-token" {
t.Errorf("unexpected token on insert")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"value":[{"id":"rec-1"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, err := NewClient(Config{
ClientID: "appKey-1",
Secret: "appSecret-1",
BaseID: "b1",
SheetID: "sh1",
OperatorID: "operator-1",
TokenURL: server.URL + "/v1.0/oauth2/accessToken",
BaseURL: server.URL,
}, nil)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
// Two inserts, only one token call.
for i := 0; i < 2; i++ {
if _, err := client.InsertRecord(context.Background(), row); err != nil {
t.Fatalf("InsertRecord %d: %v", i, err)
}
}
if tokenCalls != 1 {
t.Errorf("token calls = %d, want 1", tokenCalls)
}
}
func TestInsertRecordNon2xx(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"tok","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/b1/sheets/sh1/records", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":"invalid","message":"bad request"}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: server.URL + "/v1.0/oauth2/accessToken", BaseURL: server.URL,
}, nil)
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
_, err := client.InsertRecord(context.Background(), row)
if err == nil {
t.Fatal("expected error for non-2xx")
}
// Error must not contain token, secret, operator, or raw row values.
errStr := err.Error()
for _, secret := range []string{"tok", "op", "union-x"} {
if strings.Contains(errStr, secret) {
t.Errorf("error redaction failed: %q found in %q", secret, errStr)
}
}
}
func TestInsertRecordMalformedResponse(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"tok","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/b1/sheets/sh1/records", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`not json`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: server.URL + "/v1.0/oauth2/accessToken", BaseURL: server.URL,
}, nil)
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
_, err := client.InsertRecord(context.Background(), row)
if err == nil {
t.Fatal("expected error for malformed response")
}
}
func TestInsertRecordEmptyValueList(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"tok","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/b1/sheets/sh1/records", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"value":[]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: server.URL + "/v1.0/oauth2/accessToken", BaseURL: server.URL,
}, nil)
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
_, err := client.InsertRecord(context.Background(), row)
if err == nil {
t.Fatal("expected error for empty value list")
}
}
func TestInsertRecordEmptyRecordID(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"tok","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/b1/sheets/sh1/records", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"value":[{"id":""}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: server.URL + "/v1.0/oauth2/accessToken", BaseURL: server.URL,
}, nil)
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
_, err := client.InsertRecord(context.Background(), row)
if err == nil {
t.Fatal("expected error for empty record id")
}
}
func TestTokenCallErrorRedaction(t *testing.T) {
// Token endpoint returns non-2xx.
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":"unauthorized","message":"invalid appSecret"}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: server.URL + "/v1.0/oauth2/accessToken", BaseURL: server.URL,
}, nil)
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
_, err := client.InsertRecord(context.Background(), row)
if err == nil {
t.Fatal("expected error for token failure")
}
// Error must not contain raw body, secret, or token.
errStr := err.Error()
for _, secret := range []string{"appSecret", "invalid appSecret", "unauthorized"} {
if strings.Contains(errStr, secret) {
t.Errorf("error redaction failed: %q found in %q", secret, errStr)
}
}
}
func TestTokenEmptyAccessToken(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"","expireIn":7200}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: server.URL + "/v1.0/oauth2/accessToken", BaseURL: server.URL,
}, nil)
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
_, err := client.InsertRecord(context.Background(), row)
if err == nil {
t.Fatal("expected error for empty access token")
}
}
func TestTokenExpirySafetyMargin(t *testing.T) {
// Token with expireIn=1: TTL ≤ 30s → exp = TTL/2 (500ms). The tokenExp
// must be after now but no later than server expiry (1s) + small scheduling
// tolerance. An immediate second request should still reuse the cache.
var tokenCalls int32
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&tokenCalls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"short-token","expireIn":1}`))
})
mux.HandleFunc("/v1.0/notable/bases/b1/sheets/sh1/records", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"value":[{"id":"rec-1"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: server.URL + "/v1.0/oauth2/accessToken", BaseURL: server.URL,
}, nil)
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
// First call: token fetch.
before := time.Now()
_, err := client.InsertRecord(context.Background(), row)
if err != nil {
t.Fatalf("first InsertRecord: %v", err)
}
if tokenCalls != 1 {
t.Errorf("token calls after first insert = %d, want 1", tokenCalls)
}
// Inspect tokenExp.
client.mu.Lock()
exp := client.tokenExp
client.mu.Unlock()
// tokenExp must be after now.
if !exp.After(before) {
t.Errorf("tokenExp %v is not after request start %v", exp, before)
}
// tokenExp must not exceed server expiry (1s) + scheduling tolerance (500ms).
if exp.After(before.Add(1500 * time.Millisecond)) {
t.Errorf("tokenExp %v exceeds server expiry + tolerance", exp)
}
// Second call: should use cached token (within TTL/2 = 500ms).
_, err = client.InsertRecord(context.Background(), row)
if err != nil {
t.Fatalf("second InsertRecord: %v", err)
}
if tokenCalls != 1 {
t.Errorf("token calls after second insert = %d, want 1 (cached)", tokenCalls)
}
}
func TestTokenExpiryNonPositive(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"tok","expireIn":0}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: server.URL + "/v1.0/oauth2/accessToken", BaseURL: server.URL,
}, nil)
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
_, err := client.InsertRecord(context.Background(), row)
if err == nil {
t.Fatal("expected error for non-positive expireIn")
}
}
func TestContextTimeout(t *testing.T) {
handlerDone := make(chan struct{})
mux := http.NewServeMux()
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accessToken":"tok","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/b1/sheets/sh1/records", func(w http.ResponseWriter, r *http.Request) {
// Wait for context cancellation or test signal.
select {
case <-r.Context().Done():
case <-handlerDone:
}
})
server := httptest.NewServer(mux)
defer server.Close()
defer close(handlerDone) // runs before server.Close (LIFO), unblocks the handler
// Use a client with keep-alives disabled so the TCP connection
// closes after the handler returns — httptest.Server.Close won't hang.
httpClient := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
Timeout: 30 * time.Second,
}
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: server.URL + "/v1.0/oauth2/accessToken", BaseURL: server.URL,
}, httpClient)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
_, err := client.InsertRecord(ctx, row)
if err == nil {
t.Fatal("expected timeout error")
}
if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "context deadline") {
t.Logf("timeout error (may be wrapped): %v", err)
}
}
func TestDefaultURLs(t *testing.T) {
client, err := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
}, nil)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
if client.TokenURL() != defaultBase+"/v1.0/oauth2/accessToken" {
t.Errorf("default token URL = %q, want %q", client.TokenURL(), defaultBase+"/v1.0/oauth2/accessToken")
}
if client.BaseURL() != defaultBase {
t.Errorf("default base URL = %q, want %q", client.BaseURL(), defaultBase)
}
}
func TestCustomURLs(t *testing.T) {
client, err := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: "https://custom.example.com/token",
BaseURL: "https://custom.example.com",
}, nil)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
if client.TokenURL() != "https://custom.example.com/token" {
t.Errorf("custom token URL = %q", client.TokenURL())
}
if client.BaseURL() != "https://custom.example.com" {
t.Errorf("custom base URL = %q", client.BaseURL())
}
}
func TestNewClientNilHTTP(t *testing.T) {
client, err := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
}, nil)
if err != nil {
t.Fatalf("NewClient with nil http: %v", err)
}
if client.http == nil {
t.Fatal("http client is nil after construction with nil argument")
}
}
func TestNewClientCustomHTTP(t *testing.T) {
custom := &http.Client{Timeout: 5 * time.Second}
client, err := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
}, custom)
if err != nil {
t.Fatalf("NewClient with custom http: %v", err)
}
if client.http != custom {
t.Fatal("http client is not the custom one")
}
}
func TestDefaultTransportNoProxy(t *testing.T) {
// The default transport must have Proxy=nil.
if defaultTransport.Proxy != nil {
t.Error("defaultTransport Proxy is not nil")
}
}
func TestInsertRecordDoErrorRedaction(t *testing.T) {
// Custom RoundTripper that returns an error containing secrets.
secretErr := errors.New("connect: dial tcp https://api.dingtalk.com?operatorId=secret-op failed: row data union-x")
rt := &errorRoundTripper{err: secretErr}
client, _ := NewClient(Config{
ClientID: "c", Secret: "s", BaseID: "b1", SheetID: "sh1", OperatorID: "op",
TokenURL: "https://token.example.com", BaseURL: "https://api.example.com",
}, &http.Client{Transport: rt})
row := Row{Question: "q", Answer: "a", AskerName: "x", AskerUnionID: "union-x"}
_, err := client.InsertRecord(context.Background(), row)
if err == nil {
t.Fatal("expected error")
}
errStr := err.Error()
for _, secret := range []string{"secret-op", "union-x", "api.dingtalk.com"} {
if strings.Contains(strings.ToLower(errStr), strings.ToLower(secret)) {
t.Errorf("error redaction failed: %q found in %q", secret, errStr)
}
}
}
// errorRoundTripper returns a fixed error on every RoundTrip.
type errorRoundTripper struct {
err error
}
func (rt *errorRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return nil, rt.err
}