项目文件夹

文件
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

423 行
15 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"fmt"
"net/http"
"strings"
"sync"
"testing"
)
func TestExpandThreadReplies(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages"):
if req.URL.Query().Get("container_id") != "omt_1" {
return nil, fmt.Errorf("unexpected thread lookup: %s", req.URL.String())
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": true,
"items": []interface{}{
map[string]interface{}{
"message_id": "om_reply_1",
"msg_type": "text",
"create_time": "1710500000",
"thread_id": "omt_1",
"sender": map[string]interface{}{"name": "Alice"},
"body": map[string]interface{}{"content": `{"text":"reply @_user_1"}`},
"mentions": []interface{}{
map[string]interface{}{"key": "@_user_1", "name": "Bob"},
},
},
},
},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
messages := []map[string]interface{}{
{"message_id": "om_root_1", "thread_id": "omt_1"},
{"message_id": "om_root_2", "thread_id": "omt_1"},
{"message_id": "om_root_3", "thread_id": "omt_2"},
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 10, 1)
replies, _ := messages[0]["thread_replies"].([]map[string]interface{})
if len(replies) != 1 {
t.Fatalf("thread_replies len = %d, want 1", len(replies))
}
if replies[0]["content"] != "reply @Bob" {
t.Fatalf("thread reply content = %#v, want %#v", replies[0]["content"], "reply @Bob")
}
if messages[0]["thread_has_more"] != true {
t.Fatalf("thread_has_more = %#v, want true", messages[0]["thread_has_more"])
}
if _, ok := messages[1]["thread_replies"]; ok {
t.Fatalf("duplicate thread should not be expanded twice: %#v", messages[1]["thread_replies"])
}
if _, ok := messages[2]["thread_replies"]; ok {
t.Fatalf("total limit should stop later thread fetches: %#v", messages[2]["thread_replies"])
}
}
// TestExpandThreadRepliesResources verifies that when extractResources is on,
// each thread reply gets its own resources block with ref message_id equal to
// the reply's own message_id.
func TestExpandThreadRepliesResources(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"message_id": "om_reply_img",
"msg_type": "image",
"create_time": "1710500000",
"thread_id": "omt_1",
"sender": map[string]interface{}{"name": "Alice"},
"body": map[string]interface{}{"content": `{"image_key":"img_reply"}`},
},
},
},
}), nil
}))
messages := []map[string]interface{}{
{"message_id": "om_root_1", "thread_id": "omt_1"},
}
ExpandThreadRepliesWithResources(runtime, messages, map[string]string{}, 10, 50, true)
replies, _ := messages[0]["thread_replies"].([]map[string]interface{})
if len(replies) != 1 {
t.Fatalf("thread_replies len = %d, want 1", len(replies))
}
resources, ok := replies[0]["resources"].([]map[string]interface{})
if !ok || len(resources) != 1 {
t.Fatalf("reply resources = %#v, want 1 ref", replies[0]["resources"])
}
r := resources[0]
if r["message_id"] != "om_reply_img" || r["key"] != "img_reply" || r["type"] != "image" {
t.Fatalf("reply resource ref = %#v, want {om_reply_img,img_reply,image}", r)
}
}
// TestThreadReplyMergeForwardNested verifies that when a thread reply is itself
// a merge_forward, its sub-item resources fold into that reply's resources
// block, each ref carrying the merge_forward CONTAINER's message_id (the
// download API rejects sub-item ids with 234003 File not in msg).
func TestThreadReplyMergeForwardNested(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
// merge_forward sub-message prefetch: GET /messages/{id} (no container_id query).
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_reply_mf"):
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"message_id": "sub_in_mf",
"msg_type": "file",
"create_time": "1710500000",
"sender": map[string]interface{}{"name": "Bob"},
"body": map[string]interface{}{"content": `{"file_key":"file_in_mf"}`},
},
},
},
}), nil
// thread replies fetch: GET /messages?container_id=...
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages"):
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"message_id": "om_reply_mf",
"msg_type": "merge_forward",
"create_time": "1710500000",
"thread_id": "omt_1",
"sender": map[string]interface{}{"name": "Alice"},
"body": map[string]interface{}{"content": "[Merged forward]"},
},
},
},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
messages := []map[string]interface{}{
{"message_id": "om_root_1", "thread_id": "omt_1"},
}
ExpandThreadRepliesWithResources(runtime, messages, map[string]string{}, 10, 50, true)
replies, _ := messages[0]["thread_replies"].([]map[string]interface{})
if len(replies) != 1 {
t.Fatalf("thread_replies len = %d, want 1", len(replies))
}
resources, ok := replies[0]["resources"].([]map[string]interface{})
if !ok || len(resources) != 1 {
t.Fatalf("nested merge_forward reply resources = %#v, want 1 ref", replies[0]["resources"])
}
r := resources[0]
if r["message_id"] != "om_reply_mf" || r["key"] != "file_in_mf" || r["type"] != "file" {
t.Fatalf("nested resource ref = %#v, want {om_reply_mf,file_in_mf,file}", r)
}
}
func TestFetchThreadRepliesError(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages"):
return nil, fmt.Errorf("boom")
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
items, hasMore, err := fetchThreadReplies(runtime, "omt_fail", 5)
if items != nil {
t.Fatalf("fetchThreadReplies() items = %#v, want nil", items)
}
if hasMore {
t.Fatalf("fetchThreadReplies() hasMore = true, want false")
}
if err == nil {
t.Fatalf("fetchThreadReplies() err = nil, want non-nil")
}
}
// TestExpandThreadRepliesMultiThreadConcurrent exercises the bounded-concurrency
// multi-thread path: every distinct thread_id gets its own GET fetched in
// parallel, and the right replies land on the right outer host (the *first*
// outer message that referenced each thread_id). A race or cross-thread
// result mix-up would manifest as missing / mis-attached replies.
func TestExpandThreadRepliesMultiThreadConcurrent(t *testing.T) {
var (
mu sync.Mutex
callCount int
)
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
tid := req.URL.Query().Get("container_id")
mu.Lock()
callCount++
mu.Unlock()
// Return one synthetic reply per thread, tagged with the thread id so
// we can assert that the right replies landed on the right host.
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"message_id": "om_reply_" + tid,
"msg_type": "text",
"create_time": "1710500000",
"thread_id": tid,
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": `{"text":"reply for ` + tid + `"}`},
},
},
},
}), nil
}))
// 5 distinct thread roots → 5 planned fetches, dispatched under the
// concurrency cap. Enough to actually exercise the bounded fan-out
// rather than degenerate to the single-thread fast path.
messages := []map[string]interface{}{
{"message_id": "om_root_1", "thread_id": "omt_a"},
{"message_id": "om_root_2", "thread_id": "omt_b"},
{"message_id": "om_root_3", "thread_id": "omt_c"},
{"message_id": "om_root_4", "thread_id": "omt_d"},
{"message_id": "om_root_5", "thread_id": "omt_e"},
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 10, 500)
if callCount != 5 {
t.Fatalf("expected 5 thread fetches, got %d", callCount)
}
for i, m := range messages {
tid := m["thread_id"].(string)
replies, ok := m["thread_replies"].([]map[string]interface{})
if !ok {
t.Fatalf("message %d (thread %s) missing thread_replies: %#v", i, tid, m)
}
if len(replies) != 1 {
t.Fatalf("message %d (thread %s) replies len = %d, want 1", i, tid, len(replies))
}
// Each thread's reply was tagged with its own thread_id; verify no
// goroutine cross-contamination.
gotTid, _ := replies[0]["thread_id"].(string)
if gotTid != tid {
t.Fatalf("message %d (thread %s) got reply tagged with thread_id=%q — cross-thread contamination",
i, tid, gotTid)
}
}
}
// TestExpandThreadRepliesTotalLimitUsesActualCounts is a regression test for
// the budget-allocation refactor: the new concurrent path must deduct
// totalLimit using the *actual* returned reply count per thread, not the
// planned per-thread ceiling. Otherwise chats with many low-volume threads
// (very common — most threads in a busy group have just a few replies)
// silently drop later threads when the planned ceilings sum past totalLimit
// well before the actual replies do.
func TestExpandThreadRepliesTotalLimitUsesActualCounts(t *testing.T) {
// Synthetic API: every thread returns exactly 3 replies, regardless of
// the requested page_size. This is the "short threads" scenario where
// the difference between planned-ceiling and actual-count budget
// accounting becomes visible.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
tid := req.URL.Query().Get("container_id")
items := make([]interface{}, 3)
for i := range items {
items[i] = map[string]interface{}{
"message_id": fmt.Sprintf("om_reply_%s_%d", tid, i),
"msg_type": "text",
"create_time": "1710500000",
"thread_id": tid,
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": items,
},
}), nil
}))
// 12 distinct thread roots × 3 actual replies each = 36 total. With
// perThread=50 (the default ceiling), the old "deduct planned ceiling"
// implementation would have exhausted totalLimit=100 after just 2
// threads (2 × 50 = 100) and silently skipped the remaining 10. The
// correct behavior deducts actual counts (12 × 3 = 36 < 100), so all
// 12 threads should attach.
messages := make([]map[string]interface{}, 12)
for i := range messages {
messages[i] = map[string]interface{}{
"message_id": fmt.Sprintf("om_root_%02d", i),
"thread_id": fmt.Sprintf("omt_%02d", i),
}
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 50, 100)
for i, m := range messages {
replies, ok := m["thread_replies"].([]map[string]interface{})
if !ok {
t.Fatalf("thread %d (%s) silently dropped — thread_replies missing despite actual budget headroom",
i, m["thread_id"])
}
if len(replies) != 3 {
t.Fatalf("thread %d (%s) replies len = %d, want 3", i, m["thread_id"], len(replies))
}
}
}
// TestExpandThreadRepliesTruncatesOnBudgetBoundary covers the cross-boundary
// case: a thread whose actual replies straddle the remaining budget gets
// its slice truncated to fit and thread_has_more flagged so consumers know
// more exist server-side.
func TestExpandThreadRepliesTruncatesOnBudgetBoundary(t *testing.T) {
// Every thread returns exactly 4 replies.
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
tid := req.URL.Query().Get("container_id")
items := make([]interface{}, 4)
for i := range items {
items[i] = map[string]interface{}{
"message_id": fmt.Sprintf("om_reply_%s_%d", tid, i),
"msg_type": "text",
"create_time": "1710500000",
"thread_id": tid,
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": items,
},
}), nil
}))
// 3 threads × 4 replies = 12, but totalLimit = 10. So:
// - thread 0 fully attached (4 replies; running total 4)
// - thread 1 fully attached (4 replies; running total 8)
// - thread 2 truncated to 2 replies (running total 10), has_more=true
// - any thread 3+ would be dropped entirely
messages := []map[string]interface{}{
{"message_id": "om_root_0", "thread_id": "omt_0"},
{"message_id": "om_root_1", "thread_id": "omt_1"},
{"message_id": "om_root_2", "thread_id": "omt_2"},
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 10, 10)
for i, want := range []int{4, 4, 2} {
replies, _ := messages[i]["thread_replies"].([]map[string]interface{})
if len(replies) != want {
t.Fatalf("thread %d replies len = %d, want %d (post-budget truncation)", i, len(replies), want)
}
}
if messages[2]["thread_has_more"] != true {
t.Fatalf("thread 2 was truncated by budget but thread_has_more = %#v, want true",
messages[2]["thread_has_more"])
}
// And the truncated host must NOT be flagged with thread_replies_error —
// budget truncation is success, not failure.
for i, m := range messages {
if v, _ := m["thread_replies_error"].(bool); v {
t.Fatalf("message %d incorrectly flagged with thread_replies_error after budget truncation: %#v", i, m)
}
}
}
func TestExpandThreadRepliesMarksFetchError(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages"):
return nil, fmt.Errorf("boom")
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
messages := []map[string]interface{}{
{"message_id": "om_root_1", "thread_id": "omt_fail"},
}
ExpandThreadReplies(runtime, messages, map[string]string{}, 5, 50)
if messages[0]["thread_replies_error"] != true {
t.Fatalf("thread_replies_error = %#v, want true", messages[0]["thread_replies_error"])
}
if _, ok := messages[0]["thread_replies"]; ok {
t.Fatalf("thread_replies should be absent on fetch error: %#v", messages[0]["thread_replies"])
}
}