Files
2026-06-02 23:14:41 +08:00

859 lines
25 KiB
Go

package dingtalk
import (
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
)
// ──────────────────────────────────────────────────────────────
// Thread safety tests for token caching
// ──────────────────────────────────────────────────────────────
func TestGetAccessToken_ConcurrentAccess(t *testing.T) {
// This test verifies that concurrent calls to getAccessToken
// with a pre-cached token are properly synchronized by the mutex
p := &Platform{
clientID: "test_client",
clientSecret: "test_secret",
httpClient: &http.Client{}, // Valid HTTP client
accessToken: "test_token", // Pre-cache a token
tokenExpiry: time.Now().Add(1 * time.Hour),
}
// Launch multiple goroutines to stress-test the mutex
const numGoroutines = 100
var wg sync.WaitGroup
successCount := 0
var countMu sync.Mutex
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
token, err := p.getAccessToken()
if err == nil && token == "test_token" {
countMu.Lock()
successCount++
countMu.Unlock()
}
}()
}
wg.Wait()
// All goroutines should have gotten the cached token
if successCount != numGoroutines {
t.Errorf("expected %d successful token retrievals, got %d", numGoroutines, successCount)
}
t.Logf("Completed %d concurrent token requests without deadlock", numGoroutines)
}
func TestGetAccessToken_MutexExists(t *testing.T) {
// Verify that the tokenMu mutex field exists and works
p := &Platform{
clientID: "test_client",
clientSecret: "test_secret",
}
// Test that we can lock/unlock the mutex (verify no panic under lock)
p.tokenMu.Lock()
_ = p.clientID // SA2001: intentional empty section to verify Lock/Unlock work
p.tokenMu.Unlock()
// Test with defer
p.tokenMu.Lock()
defer p.tokenMu.Unlock()
t.Log("tokenMu mutex is functional")
}
func TestGetAccessToken_CachedTokenAccess(t *testing.T) {
// Test that cached token access is thread-safe
p := &Platform{
clientID: "test_client",
clientSecret: "test_secret",
accessToken: "cached_token",
tokenExpiry: time.Now().Add(1 * time.Hour),
}
const numGoroutines = 50
var wg sync.WaitGroup
tokens := make([]string, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
token, err := p.getAccessToken()
if err == nil {
tokens[idx] = token
}
}(i)
}
wg.Wait()
// Verify all goroutines got the same cached token
for i, token := range tokens {
if token != "" && token != "cached_token" {
t.Errorf("goroutine %d: expected cached token 'cached_token', got %q", i, token)
}
}
t.Logf("All %d goroutines safely accessed cached token", numGoroutines)
}
func TestPlatform_MutexFieldExists(t *testing.T) {
// Verify the Platform struct has the tokenMu field
p := &Platform{}
// Verify no panic under lock (test will fail to compile if tokenMu doesn't exist)
p.tokenMu.Lock()
_ = p.clientID // SA2001: intentional empty section to verify Lock/Unlock work
p.tokenMu.Unlock()
t.Log("Platform.tokenMu field exists")
}
func TestPlatform_AccessTokenFieldsExist(t *testing.T) {
// Verify the Platform struct has the token caching fields
p := &Platform{}
// Set the fields
p.accessToken = "test_token"
p.tokenExpiry = time.Now().Add(1 * time.Hour)
// Verify they're set
if p.accessToken != "test_token" {
t.Errorf("expected accessToken 'test_token', got %q", p.accessToken)
}
t.Log("Platform token caching fields exist and are accessible")
}
// ──────────────────────────────────────────────────────────────
// ReconstructReplyCtx tests
// ──────────────────────────────────────────────────────────────
func TestReconstructReplyCtx_GroupSharedSession(t *testing.T) {
p := &Platform{}
rctx, err := p.ReconstructReplyCtx("dingtalk:g:conv123")
if err != nil {
t.Fatalf("ReconstructReplyCtx() error = %v", err)
}
rc := rctx.(replyContext)
if rc.conversationId != "conv123" {
t.Errorf("conversationId = %q, want %q", rc.conversationId, "conv123")
}
if rc.senderStaffId != "" {
t.Errorf("senderStaffId = %q, want empty", rc.senderStaffId)
}
if !rc.isGroup {
t.Error("isGroup = false, want true for group session")
}
if !rc.proactive {
t.Error("proactive = false, want true")
}
}
func TestReconstructReplyCtx_GroupPerUserSession(t *testing.T) {
p := &Platform{}
rctx, err := p.ReconstructReplyCtx("dingtalk:g:conv123:user456")
if err != nil {
t.Fatalf("ReconstructReplyCtx() error = %v", err)
}
rc := rctx.(replyContext)
if rc.conversationId != "conv123" {
t.Errorf("conversationId = %q, want %q", rc.conversationId, "conv123")
}
if rc.senderStaffId != "user456" {
t.Errorf("senderStaffId = %q, want %q", rc.senderStaffId, "user456")
}
if !rc.isGroup {
t.Error("isGroup = false, want true for group session")
}
}
func TestReconstructReplyCtx_DirectSession(t *testing.T) {
p := &Platform{}
rctx, err := p.ReconstructReplyCtx("dingtalk:d:conv789:user111")
if err != nil {
t.Fatalf("ReconstructReplyCtx() error = %v", err)
}
rc := rctx.(replyContext)
if rc.conversationId != "conv789" {
t.Errorf("conversationId = %q, want %q", rc.conversationId, "conv789")
}
if rc.senderStaffId != "user111" {
t.Errorf("senderStaffId = %q, want %q", rc.senderStaffId, "user111")
}
if rc.isGroup {
t.Error("isGroup = true, want false for direct session")
}
if !rc.proactive {
t.Error("proactive = false, want true")
}
}
func TestReconstructReplyCtx_InvalidPrefix(t *testing.T) {
p := &Platform{}
_, err := p.ReconstructReplyCtx("telegram:g:conv123")
if err == nil {
t.Fatal("expected error for non-dingtalk prefix")
}
}
func TestReconstructReplyCtx_InvalidConvType(t *testing.T) {
p := &Platform{}
_, err := p.ReconstructReplyCtx("dingtalk:x:conv123")
if err == nil {
t.Fatal("expected error for invalid conversation type")
}
}
func TestReconstructReplyCtx_EmptyConversationId(t *testing.T) {
p := &Platform{}
_, err := p.ReconstructReplyCtx("dingtalk:g:")
if err == nil {
t.Fatal("expected error for empty conversationId")
}
}
func TestReconstructReplyCtx_TooFewParts(t *testing.T) {
p := &Platform{}
_, err := p.ReconstructReplyCtx("dingtalk:")
if err == nil {
t.Fatal("expected error for too few parts")
}
}
// ──────────────────────────────────────────────────────────────
// formatReplyContent tests
// ──────────────────────────────────────────────────────────────
func TestFormatReplyContent_WithQuotedText(t *testing.T) {
p := &Platform{}
repliedContent, _ := json.Marshal(repliedTextContent{Text: "original message"})
richText := &richTextContent{
Content: "user reply",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "text",
Content: repliedContent,
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"original message\"\n\nuser reply"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_EmptyContent_UsesFallback(t *testing.T) {
p := &Platform{}
repliedContent, _ := json.Marshal(repliedTextContent{Text: "quoted"})
richText := &richTextContent{
Content: "",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "text",
Content: repliedContent,
},
}
result := p.formatReplyContent(richText, "fallback text")
expected := "引用: \"quoted\"\n\nfallback text"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_TextQuotePreservesWhitespace(t *testing.T) {
p := &Platform{}
repliedContent, _ := json.Marshal(repliedTextContent{Text: " original message "})
richText := &richTextContent{
Content: "user reply",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "text",
Content: repliedContent,
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \" original message \"\n\nuser reply"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_NilRepliedMsg(t *testing.T) {
p := &Platform{}
richText := &richTextContent{
Content: "just a message",
IsReplyMsg: true,
RepliedMsg: nil,
}
result := p.formatReplyContent(richText, "fallback")
if result != "just a message" {
t.Errorf("formatReplyContent() = %q, want %q", result, "just a message")
}
}
func TestFormatReplyContent_NonTextMsgType(t *testing.T) {
p := &Platform{}
richText := &richTextContent{
Content: "user reply",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "image",
Content: json.RawMessage(`{}`),
},
}
result := p.formatReplyContent(richText, "fallback")
if result != "user reply" {
t.Errorf("formatReplyContent() = %q, want %q", result, "user reply")
}
}
func TestFormatReplyContent_WithQuotedInteractiveCardContent(t *testing.T) {
p := &Platform{cardTemplateKey: "content"}
richText := &richTextContent{
Content: "user reply",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"cardData": {
"cardParamMap": {
"config": "{\"autoLayout\":true}",
"content": "bot card answer"
}
}
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"bot card answer\"\n\nuser reply"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_WithQuotedInteractiveCardCustomTemplateKey(t *testing.T) {
p := &Platform{cardTemplateKey: "body"}
richText := &richTextContent{
Content: "next question",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"cardData": {
"cardParamMap": {
"content": "default content",
"body": "custom body content"
}
}
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"custom body content\"\n\nnext question"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_WithQuotedInteractiveCardNestedJSONEnvelope(t *testing.T) {
p := &Platform{cardTemplateKey: "content"}
richText := &richTextContent{
Content: "continue",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"cardData": "{\"cardParamMap\":{\"content\":\"nested card answer\"}}"
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"nested card answer\"\n\ncontinue"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_WithQuotedInteractiveCardTopLevelFallback(t *testing.T) {
p := &Platform{}
richText := &richTextContent{
Content: "what next?",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"title": "Run Summary",
"markdown": "all checks passed"
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"all checks passed\"\n\nwhat next?"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_InteractiveCardPreservesVisibleJSONContent(t *testing.T) {
p := &Platform{cardTemplateKey: "content"}
richText := &richTextContent{
Content: "follow up",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"cardData": {
"cardParamMap": {
"config": "{\"autoLayout\":true}",
"content": "{\"status\":\"ok\"}"
}
}
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"{\"status\":\"ok\"}\"\n\nfollow up"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_InteractiveCardTopLevelFallbackIgnoresCustomKey(t *testing.T) {
p := &Platform{cardTemplateKey: "body"}
richText := &richTextContent{
Content: "follow up",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"body": "custom top-level body",
"content": "top-level content"
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"top-level content\"\n\nfollow up"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_TruncatesLongQuotedInteractiveCardContent(t *testing.T) {
p := &Platform{cardTemplateKey: "content"}
longText := strings.Repeat("x", maxQuotedMessageRunes+1)
cardContent, err := json.Marshal(map[string]any{
"cardData": map[string]any{
"cardParamMap": map[string]string{
"content": longText,
},
},
})
if err != nil {
t.Fatalf("marshal card content: %v", err)
}
richText := &richTextContent{
Content: "short reply",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: cardContent,
},
}
result := p.formatReplyContent(richText, "fallback")
expectedPrefix := "引用: \"" + strings.Repeat("x", maxQuotedMessageRunes) + "...\"\n\nshort reply"
if result != expectedPrefix {
t.Errorf("formatReplyContent() length = %d, want truncated output length %d", len([]rune(result)), len([]rune(expectedPrefix)))
}
}
func TestOnRawMessage_QuotedInteractiveCardEnrichesMessageContent(t *testing.T) {
var got *core.Message
p := &Platform{
cardTemplateKey: "content",
handler: func(_ core.Platform, msg *core.Message) {
got = msg
},
}
p.onRawMessage(`{
"msgtype": "text",
"msgId": "msg-1",
"conversationType": "2",
"conversationId": "conv-1",
"conversationTitle": "team chat",
"senderStaffId": "user-1",
"senderNick": "Alice",
"sessionWebhook": "https://example.invalid/webhook",
"text": {
"content": "please continue",
"isReplyMsg": true,
"repliedMsg": {
"msgType": "interactiveCard",
"content": {
"cardData": {
"cardParamMap": {
"content": "previous card answer"
}
}
}
}
}
}`)
if got == nil {
t.Fatal("handler was not called")
}
expected := "引用: \"previous card answer\"\n\nplease continue"
if got.Content != expected {
t.Errorf("message content = %q, want %q", got.Content, expected)
}
}
func TestFormatReplyContent_EmptyQuotedText(t *testing.T) {
p := &Platform{}
repliedContent, _ := json.Marshal(repliedTextContent{Text: ""})
richText := &richTextContent{
Content: "user reply",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "text",
Content: repliedContent,
},
}
result := p.formatReplyContent(richText, "fallback")
if result != "user reply" {
t.Errorf("formatReplyContent() = %q, want %q", result, "user reply")
}
}
// ──────────────────────────────────────────────────────────────
// Proactive routing tests
// ──────────────────────────────────────────────────────────────
func TestProactiveRouting_GroupSessionUsesGroupAPI(t *testing.T) {
// Verify that a group session key produces a replyContext with isGroup=true,
// which sendProactiveMessage would route to groupMessages/send.
p := &Platform{}
rctx, err := p.ReconstructReplyCtx("dingtalk:g:conv123:user456")
if err != nil {
t.Fatalf("ReconstructReplyCtx() error = %v", err)
}
rc := rctx.(replyContext)
if !rc.isGroup || rc.conversationId == "" {
t.Errorf("group routing: isGroup=%v, conversationId=%q; want isGroup=true with non-empty conversationId", rc.isGroup, rc.conversationId)
}
}
func TestProactiveRouting_DirectSessionUsesDirectAPI(t *testing.T) {
// Verify that a direct session key produces a replyContext with isGroup=false,
// which sendProactiveMessage would route to oToMessages/batchSend.
p := &Platform{}
rctx, err := p.ReconstructReplyCtx("dingtalk:d:conv789:user111")
if err != nil {
t.Fatalf("ReconstructReplyCtx() error = %v", err)
}
rc := rctx.(replyContext)
if rc.isGroup {
t.Error("direct routing: isGroup=true, want false for 1:1 session")
}
if rc.senderStaffId != "user111" {
t.Errorf("direct routing: senderStaffId=%q, want %q", rc.senderStaffId, "user111")
}
}
// ──────────────────────────────────────────────────────────────
// extractRichText tests (from main: richText message type support)
// ──────────────────────────────────────────────────────────────
func TestExtractRichText(t *testing.T) {
tests := []struct {
name string
content interface{}
want string
}{
{
name: "nil content",
content: nil,
want: "",
},
{
name: "non-map content",
content: "not a map",
want: "",
},
{
name: "empty richText array",
content: map[string]interface{}{
"richText": []interface{}{},
},
want: "",
},
{
name: "single text element",
content: map[string]interface{}{
"richText": []interface{}{
map[string]interface{}{"text": "Hello World"},
},
},
want: "Hello World",
},
{
name: "multiple text elements",
content: map[string]interface{}{
"richText": []interface{}{
map[string]interface{}{"text": "Hello "},
map[string]interface{}{"text": "World"},
},
},
want: "Hello World",
},
{
name: "text with attrs (bold etc) — attrs ignored, text extracted",
content: map[string]interface{}{
"richText": []interface{}{
map[string]interface{}{"text": "normal "},
map[string]interface{}{"text": "bold", "attrs": map[string]interface{}{"bold": true}},
},
},
want: "normal bold",
},
{
name: "mixed text and picture elements — pictures extracted",
content: map[string]interface{}{
"richText": []interface{}{
map[string]interface{}{"text": "See image: "},
map[string]interface{}{"pictureDownloadCode": "abc123"},
map[string]interface{}{"text": "done"},
},
},
want: "See image: done",
},
{
name: "missing richText key",
content: map[string]interface{}{
"other": "data",
},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, _ := extractRichText(tt.content)
if got != tt.want {
t.Errorf("extractRichText() = %q, want %q", got, tt.want)
}
})
}
}
func TestExtractRichText_ImageDownloadCodes(t *testing.T) {
tests := []struct {
name string
content map[string]interface{}
wantCodes []string
}{
{
name: "single image",
content: map[string]interface{}{
"richText": []interface{}{
map[string]interface{}{"text": "Look: "},
map[string]interface{}{"pictureDownloadCode": "img_001"},
},
},
wantCodes: []string{"img_001"},
},
{
name: "multiple images",
content: map[string]interface{}{
"richText": []interface{}{
map[string]interface{}{"pictureDownloadCode": "img_001"},
map[string]interface{}{"text": " and "},
map[string]interface{}{"pictureDownloadCode": "img_002"},
},
},
wantCodes: []string{"img_001", "img_002"},
},
{
name: "no images",
content: map[string]interface{}{
"richText": []interface{}{
map[string]interface{}{"text": "just text"},
},
},
wantCodes: nil,
},
{
name: "empty pictureDownloadCode ignored",
content: map[string]interface{}{
"richText": []interface{}{
map[string]interface{}{"pictureDownloadCode": ""},
},
},
wantCodes: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, gotCodes := extractRichText(tt.content)
if len(gotCodes) != len(tt.wantCodes) {
t.Fatalf("got %d codes, want %d: %v", len(gotCodes), len(tt.wantCodes), gotCodes)
}
for i, code := range gotCodes {
if code != tt.wantCodes[i] {
t.Errorf("code[%d] = %q, want %q", i, code, tt.wantCodes[i])
}
}
})
}
}
// ──────────────────────────────────────────────────────────────
// Token expiry fallback when server returns missing/invalid expireIn
// ──────────────────────────────────────────────────────────────
// fakeAccessTokenRT serves a single canned /oauth2/accessToken response
// regardless of the request URL — enough to exercise getAccessToken's
// caching arithmetic without hitting the real DingTalk API.
type fakeAccessTokenRT struct {
body string
}
func (f *fakeAccessTokenRT) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(f.body)),
Header: make(http.Header),
Request: req,
}, nil
}
func TestOnRawMessage_PictureMsgTypeNotDroppedAsEmptyText(t *testing.T) {
// Regression test for #1128: DingTalk sometimes sends msgtype="picture"
// for image messages. Before the fix, this fell through to the text handler,
// which produced an empty-content message that was silently dropped by the engine.
// After the fix, the message is routed to handleImageMessage instead.
//
// We cannot easily mock the full HTTP download path here. The test verifies the
// negative: for msgtype="picture" the handler must NOT be called with empty content.
// handleImageMessage may panic on the nil httpClient; we recover from that.
var handlerCalledWithEmptyContent bool
func() {
defer func() { recover() }() // handleImageMessage panics on nil httpClient — that's OK
p := &Platform{
handler: func(_ core.Platform, msg *core.Message) {
if msg.Content == "" && len(msg.Images) == 0 {
handlerCalledWithEmptyContent = true
}
},
}
p.onRawMessage(`{
"msgtype": "picture",
"msgId": "msg-pic-1",
"conversationType": "1",
"conversationId": "conv-1",
"conversationTitle": "test",
"senderStaffId": "user-1",
"senderNick": "Alice",
"sessionWebhook": "https://example.invalid/webhook",
"content": {"downloadCode": "some-code"}
}`)
}()
// The message is routed to handleImageMessage (which fails at download — no mock),
// not to the text handler. The handler must NOT be invoked with empty content.
if handlerCalledWithEmptyContent {
t.Error("msgtype=picture: handler called with empty content (image was silently dropped as text)")
}
}
func TestGetAccessToken_ZeroExpireIn_FallsBackToDefault(t *testing.T) {
p := &Platform{
clientID: "test_client",
clientSecret: "test_secret",
httpClient: &http.Client{
Transport: &fakeAccessTokenRT{body: `{"accessToken":"tok-zero","expireIn":0}`},
},
}
before := time.Now()
tok, err := p.getAccessToken()
if err != nil {
t.Fatalf("getAccessToken() error = %v", err)
}
if tok != "tok-zero" {
t.Fatalf("token = %q, want %q", tok, "tok-zero")
}
// Without the fallback, tokenExpiry would land at "before" (now+0s), making
// time.Now().Before(tokenExpiry) immediately false — every subsequent call
// would re-fetch a token. Assert the cache window is meaningful (>= 1h).
gotWindow := p.tokenExpiry.Sub(before)
if gotWindow < time.Hour {
t.Errorf("tokenExpiry window = %v from response, want >= 1h (zero-expireIn should fall back, not cache for 0s)", gotWindow)
}
}
func TestGetAccessToken_NegativeExpireIn_FallsBackToDefault(t *testing.T) {
p := &Platform{
clientID: "test_client",
clientSecret: "test_secret",
httpClient: &http.Client{
Transport: &fakeAccessTokenRT{body: `{"accessToken":"tok-neg","expireIn":-1}`},
},
}
before := time.Now()
if _, err := p.getAccessToken(); err != nil {
t.Fatalf("getAccessToken() error = %v", err)
}
if p.tokenExpiry.Sub(before) < time.Hour {
t.Errorf("tokenExpiry window for expireIn=-1 = %v, want >= 1h", p.tokenExpiry.Sub(before))
}
}
func TestGetAccessToken_NormalExpireIn_AppliesBuffer(t *testing.T) {
p := &Platform{
clientID: "test_client",
clientSecret: "test_secret",
httpClient: &http.Client{
Transport: &fakeAccessTokenRT{body: `{"accessToken":"tok-7200","expireIn":7200}`},
},
}
before := time.Now()
if _, err := p.getAccessToken(); err != nil {
t.Fatalf("getAccessToken() error = %v", err)
}
// 7200 - 300 buffer = 6900s = 115min. Allow tolerance for elapsed time.
gotWindow := p.tokenExpiry.Sub(before)
if gotWindow < 100*time.Minute || gotWindow > 116*time.Minute {
t.Errorf("tokenExpiry window for expireIn=7200 = %v, want ~6900s (100-116min)", gotWindow)
}
}