Files
larksuite-cli/shortcuts/im/convert_lib/helpers_test.go
haozhenghua-code db7d3cb64d fix(im): cap basic_batch user_ids at 10 per API limit (#551)
The POST /contact/v3/users/basic_batch endpoint caps user_ids at 1~10
per request, but batchResolveByBasicContact was chunking by 50. When
user identity needed to resolve >10 unresolved sender names, the
single oversized request was rejected, causing the batch resolver to
bail out and leave sender names empty for the rest.

Lower batchSize to 10 and add a unit test that exercises 25 missing
IDs and asserts they are sent as 10 / 10 / 5.
2026-04-18 18:41:30 +08:00

243 lines
7.7 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"testing"
"time"
)
func TestParseJSONObject(t *testing.T) {
got, err := ParseJSONObject(`{"text":"hello","count":2}`)
if err != nil {
t.Fatalf("ParseJSONObject() error = %v", err)
}
if got["text"] != "hello" {
t.Fatalf("ParseJSONObject() text = %#v, want %#v", got["text"], "hello")
}
if invalid, err := ParseJSONObject(`{invalid`); err == nil || invalid != nil {
t.Fatalf("ParseJSONObject() invalid JSON = (%#v, %v), want (nil, err)", invalid, err)
}
}
func TestBuildMentionKeyMap(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{"key": "@_user_1", "name": "Alice"},
map[string]interface{}{"key": "@_user_2", "name": "Bob"},
map[string]interface{}{"key": "", "name": "Ignored"},
map[string]interface{}{"key": "@_user_3"},
}
got := BuildMentionKeyMap(mentions)
want := map[string]string{
"@_user_1": "Alice",
"@_user_2": "Bob",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("BuildMentionKeyMap() = %#v, want %#v", got, want)
}
}
func TestResolveMentionKeys(t *testing.T) {
got := ResolveMentionKeys("hi @_user_1 and @_user_2", map[string]string{
"@_user_1": "Alice",
"@_user_2": "Bob",
})
want := "hi @Alice and @Bob"
if got != want {
t.Fatalf("ResolveMentionKeys() = %q, want %q", got, want)
}
}
func TestFormatTimestamp(t *testing.T) {
sec := int64(1710500000)
want := time.Unix(sec, 0).Local().Format("2006-01-02 15:04:05")
if got := formatTimestamp("1710500000"); got != want {
t.Fatalf("formatTimestamp(seconds) = %q, want %q", got, want)
}
if got := formatTimestamp("1710500000000"); got != want {
t.Fatalf("formatTimestamp(milliseconds) = %q, want %q", got, want)
}
if got := formatTimestamp(""); got != "" {
t.Fatalf("formatTimestamp(empty) = %q, want empty", got)
}
if got := formatTimestamp("not-a-number"); got != "" {
t.Fatalf("formatTimestamp(invalid) = %q, want empty", got)
}
futureSec := int64(10000000000)
wantFuture := time.Unix(futureSec, 0).Local().Format("2006-01-02 15:04:05")
if got := formatTimestamp("10000000000"); got != wantFuture {
t.Fatalf("formatTimestamp(future seconds) = %q, want %q", got, wantFuture)
}
}
func TestAttachSenderNames(t *testing.T) {
messages := []map[string]interface{}{
{"sender": map[string]interface{}{"id": "ou_alice"}},
{"sender": map[string]interface{}{"id": "ou_bob", "name": "Existing"}},
{"sender": map[string]interface{}{"id": "ou_carol"}},
{"sender": "not-a-map"},
}
nameMap := map[string]string{"ou_alice": "Alice"}
AttachSenderNames(messages, nameMap)
sender1 := messages[0]["sender"].(map[string]interface{})
if sender1["name"] != "Alice" {
t.Fatalf("AttachSenderNames() resolved name = %#v, want %#v", sender1["name"], "Alice")
}
sender2 := messages[1]["sender"].(map[string]interface{})
if sender2["name"] != "Existing" {
t.Fatalf("AttachSenderNames() existing name = %#v, want %#v", sender2["name"], "Existing")
}
sender3 := messages[2]["sender"].(map[string]interface{})
if _, hasName := sender3["name"]; hasName {
t.Fatalf("AttachSenderNames() unresolved sender should have no name, got %#v", sender3["name"])
}
}
func TestExtractPostBlocksText(t *testing.T) {
blocks := []interface{}{
[]interface{}{
map[string]interface{}{"tag": "text", "text": "hello "},
map[string]interface{}{"tag": "at", "user_name": "Alice"},
map[string]interface{}{"tag": "text", "text": " "},
map[string]interface{}{"tag": "a", "text": "docs", "href": "https://example.com"},
},
[]interface{}{
map[string]interface{}{"tag": "img", "image_key": "img_123"},
},
[]interface{}{},
}
got := extractPostBlocksText(blocks)
want := "hello @Alice [docs](https://example.com)\n[Image: img_123]"
if got != want {
t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
}
}
func TestResolveSenderNames(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/batch"):
if got := req.URL.Query()["user_ids"]; !reflect.DeepEqual(got, []string{"ou_api", "ou_missing"}) {
t.Fatalf("contact batch user_ids = %#v, want %#v", got, []string{"ou_api", "ou_missing"})
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"open_id": "ou_api", "name": "API User"},
},
},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
messages := []map[string]interface{}{
{
"sender": map[string]interface{}{"sender_type": "user", "id": "ou_mention"},
"mentions": []interface{}{
map[string]interface{}{"id": "ou_mention", "name": "Mention User"},
},
},
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_api"}},
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_missing"}},
{"sender": map[string]interface{}{"sender_type": "bot", "id": "cli_1"}},
}
got := ResolveSenderNames(runtime, messages, nil)
if got["ou_mention"] != "Mention User" {
t.Fatalf("mention-resolved sender = %#v, want %#v", got["ou_mention"], "Mention User")
}
if got["ou_api"] != "API User" {
t.Fatalf("api-resolved sender = %#v, want %#v", got["ou_api"], "API User")
}
if got["ou_missing"] != "" {
t.Fatalf("missing sender = %#v, want empty", got["ou_missing"])
}
}
func TestBatchResolveByBasicContactRespectsAPILimit(t *testing.T) {
// basic_batch allows at most 10 user_ids per request. Given 25 missing IDs,
// expect three requests with sizes 10 / 10 / 5.
var batchSizes []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/basic_batch") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
userIDs, _ := payload["user_ids"].([]interface{})
if len(userIDs) > 10 {
t.Fatalf("batch exceeded API limit: size = %d", len(userIDs))
}
batchSizes = append(batchSizes, len(userIDs))
users := make([]interface{}, 0, len(userIDs))
for _, raw := range userIDs {
id, _ := raw.(string)
users = append(users, map[string]interface{}{
"user_id": id,
"name": "name-" + id,
})
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"users": users},
}), nil
}))
missingIDs := make([]string, 25)
for i := range missingIDs {
missingIDs[i] = fmt.Sprintf("ou_%02d", i)
}
nameMap := map[string]string{}
batchResolveByBasicContact(runtime, missingIDs, nameMap)
if want := []int{10, 10, 5}; !reflect.DeepEqual(batchSizes, want) {
t.Fatalf("batch sizes = %v, want %v", batchSizes, want)
}
if len(nameMap) != 25 {
t.Fatalf("resolved name count = %d, want 25", len(nameMap))
}
}
func TestResolveSenderNamesAPIFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/batch"):
return nil, fmt.Errorf("contact api failed")
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
got := ResolveSenderNames(runtime, []map[string]interface{}{
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_fail"}},
}, map[string]string{})
if got["ou_fail"] != "" {
t.Fatalf("failed sender resolution = %#v, want empty", got["ou_fail"])
}
}