Files
larksuite__cli/shortcuts/im/convert_lib/folder_test.go
xuzhigang 7fd6ef3c07 feat: expand folder children one level in IM message output (#2606)
mget / list / search / thread list 读到 folder 消息时,展开一层子项渲染进
folder 标签(cap-10 + has_more + child_count);sub-folder 不递归只带
child_count 深度提示。converter 支持 prefetch 缓存复用 + 并发安全。

1.改动原因
消息内 folder 附件此前只渲染单行标签,用户看不到内容也无法直接取到
子文件 key 去下载;本次在渲染层展开一层子项,sub-folder 保留 key 供
im files folder 继续展开。

2.影响范围
shortcuts/im 消息渲染链 + lark-im skill 文档

| 文件 | 函数 | 改动前 | 改动后 |
|------|------|--------|--------|
| convert_lib/misc.go | folderConverter.Convert | 单行 <folder/> | 展开一层(cap-10/has_more/child_count),folderWarnf 并发安全告警 |
| convert_lib/content_convert.go | ConvertContext | — | +FolderChildren prefetch 缓存 |
| convert_lib/merge.go/text.go/thread.go | 渲染 | 无展开 | 接入 folder 展开 + prefetch |
| im_*(list/mget/search/threads) | 命令 | — | 单次 prefetch 复用 |
| folder_test.go | 单测 | — | C1-C6 覆盖 |
| skills/lark-im/references/*.md | 文档 | — | folder 展开/下载指引(用 im files folder,非 raw GET) |

3.是否引入测试
是(folder_test.go 渲染单测;convert_lib 全绿)

4.是否申请ACL
不需要
2026-09-04 18:34:52 +08:00

399 lines
17 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// fetchFolderChildrenTree unit tests (httpmock, no real openapi).
// Covers XML one-level output (folder key+name+child_count / file key+name /
// sub-folder key+name+child_count / has_more) and fallback paths.
// folderChildrenRoundTrip answers any GET /files/:key/folder request with a
// one-child expansion (or a failure when fail is true). Built on the roundtrip
// runtime with a static token resolver so the concurrent prefetch fan-out does
// not race the credential provider under -race (same approach as merge tests).
func folderChildrenRoundTrip(fail bool) convertlibRoundTripFunc {
return func(req *http.Request) (*http.Response, error) {
if fail {
return convertlibJSONResponse(503, map[string]interface{}{}), nil
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"file_key": "f1", "name": "a.pdf", "is_folder": false}},
"all_count": float64(1),
},
}), nil
}
}
func folderTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser)
return rt, reg
}
const folderURL = "/open-apis/im/v1/files/fld_root/folder?recursive=false&srcid=om_123&srctype=message"
// C4: expand one level (files + sub-folder with child_count), no has_more (items == all_count).
func TestFetchFolderChildrenTree_XMLOneLevel(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: folderURL,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"file_key": "f1", "name": "报告.pdf", "is_folder": false},
map[string]interface{}{"file_key": "f2", "name": "文档.docx", "is_folder": false},
map[string]interface{}{"file_key": "f3", "name": "子文件夹", "is_folder": true, "children_count": float64(3)},
},
"all_count": float64(3),
},
},
})
got := fetchFolderChildrenTree(rt, "fld_root", "tmpavatra", "om_123")
want := `<folder key="fld_root" name="tmpavatra" child_count="3"><file key="f1" name="报告.pdf"/><file key="f2" name="文档.docx"/><folder key="f3" name="子文件夹" child_count="3"/></folder>`
if got != want {
t.Fatalf("fetchFolderChildrenTree() = %q, want %q", got, want)
}
}
// C4b: sub-folder with children_count missing (unknown) omits child_count instead of "0".
func TestFetchFolderChildrenTree_SubFolderUnknownCount(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: folderURL,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"file_key": "f3", "name": "子文件夹", "is_folder": true}, // no children_count
},
"all_count": float64(1),
},
},
})
got := fetchFolderChildrenTree(rt, "fld_root", "x", "om_123")
want := `<folder key="fld_root" name="x" child_count="1"><folder key="f3" name="子文件夹"/></folder>`
if got != want {
t.Fatalf("fetchFolderChildrenTree() = %q, want %q", got, want)
}
}
// C4c: items < all_count -> root folder carries has_more="true".
func TestFetchFolderChildrenTree_HasMore(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: folderURL,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"file_key": "f1", "name": "a.pdf", "is_folder": false},
},
"all_count": float64(100),
},
},
})
got := fetchFolderChildrenTree(rt, "fld_root", "big", "om_123")
want := `<folder key="fld_root" name="big" child_count="100" has_more="true"><file key="f1" name="a.pdf"/></folder>`
if got != want {
t.Fatalf("fetchFolderChildrenTree() = %q, want %q", got, want)
}
}
// C4d: more than 10 first-level items -> render first 10 + has_more="true" (rendering cap).
func TestFetchFolderChildrenTree_CapAtTen(t *testing.T) {
rt, reg := folderTestRuntime(t)
items := make([]interface{}, 0, 12)
for i := 0; i < 12; i++ {
items = append(items, map[string]interface{}{"file_key": fmt.Sprintf("f%d", i), "name": fmt.Sprintf("f%d.pdf", i), "is_folder": false})
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: folderURL,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": items,
"all_count": float64(12),
},
},
})
got := fetchFolderChildrenTree(rt, "fld_root", "big", "om_123")
// first 10 rendered, has_more present, last item f11 absent
if !strings.Contains(got, `child_count="12" has_more="true"`) {
t.Fatalf("want child_count=12 + has_more, got %q", got)
}
if !strings.Contains(got, `<file key="f9" name="f9.pdf"/>`) || strings.Contains(got, `name="f10.pdf"`) {
t.Fatalf("want first 10 items only, got %q", got)
}
}
// C5: transport error (no stub registered) -> "" so callers fall back; warning goes to stderr.
func TestFetchFolderChildrenTree_APIFailure(t *testing.T) {
rt, _ := folderTestRuntime(t)
got := fetchFolderChildrenTree(rt, "fld_root", "x", "om_123")
if got != "" {
t.Fatalf("fetchFolderChildrenTree() on API failure = %q, want empty (caller downgrades)", got)
}
}
// C5b: HTTP 200 + business code != 0 (e.g. permission denied) -> "" + stderr warning.
func TestFetchFolderChildrenTree_BusinessError(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: folderURL,
Body: map[string]interface{}{"code": 14009, "msg": "dlp deny"},
})
got := fetchFolderChildrenTree(rt, "fld_root", "x", "om_123")
if got != "" {
t.Fatalf("fetchFolderChildrenTree() on business error = %q, want empty", got)
}
}
// C6: genuinely empty folder (items empty, all_count 0) keeps child_count="0"
// instead of degrading to the pre-expand single-line output.
func TestFetchFolderChildrenTree_EmptyFolder(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: folderURL,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{},
"all_count": float64(0),
},
},
})
got := fetchFolderChildrenTree(rt, "fld_root", "x", "om_123")
want := `<folder key="fld_root" name="x" child_count="0"/>`
if got != want {
t.Fatalf("fetchFolderChildrenTree() empty folder = %q, want %q", got, want)
}
}
// Prefetch: multiple folder messages in one page -> concurrent prefetch returns
// a cache keyed by message_id; Convert reuses it without a second GET.
func TestPrefetchFolderChildren(t *testing.T) {
// roundtrip runtime (static token resolver): concurrent prefetch fan-out
// must not trip the credential resolver under -race, same as merge tests.
rt := newBotConvertlibRuntime(t, folderChildrenRoundTrip(false))
rawItems := []interface{}{
map[string]interface{}{"msg_type": "folder", "message_id": "om_100",
"body": map[string]interface{}{"content": `{"file_key":"fld_om_100","file_name":"d1"}`}},
map[string]interface{}{"msg_type": "folder", "message_id": "om_200",
"body": map[string]interface{}{"content": `{"file_key":"fld_om_200","file_name":"d2"}`}},
map[string]interface{}{"msg_type": "text", "message_id": "om_300"}, // non-folder ignored
}
cache := PrefetchFolderChildren(rt, rawItems)
if len(cache) != 2 {
t.Fatalf("PrefetchFolderChildren() = %d entries, want 2 (om_100/om_200)", len(cache))
}
for _, mid := range []string{"om_100", "om_200"} {
xml, ok := cache[mid]
if !ok || !strings.Contains(xml, `<folder key="fld_`+mid) || !strings.Contains(xml, `name="d`) {
t.Fatalf("cache[%s] = %q, want expanded folder XML", mid, xml)
}
}
}
// Convert fast path: cached FolderChildren XML is reused (no second stub needed).
func TestFolderConverter_ConvertUsesPrefetchCache(t *testing.T) {
rt, _ := folderTestRuntime(t)
ctx := &ConvertContext{
RawContent: `{"file_key":"fld_x","file_name":"Docs"}`,
MessageID: "om_9",
Runtime: rt,
FolderChildren: map[string]string{"om_9": `<folder key="fld_x" name="Docs" child_count="1"><file key="f1" name="a.pdf"/></folder>`},
}
got := (folderConverter{}).Convert(ctx)
if got != `<folder key="fld_x" name="Docs" child_count="1"><file key="f1" name="a.pdf"/></folder>` {
t.Fatalf("Convert() = %q, want cached XML (fast path)", got)
}
}
// Convert-level inline expansion: folderConverter.Convert issues the GET when no
// prefetch cache is present, then renders the expanded XML (regression guard so
// removing the Convert-level call fails this test).
func TestFolderConverter_ConvertInline(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/im/v1/files/fld_root/folder?recursive=false&srcid=om_123&srctype=message",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"file_key": "f1", "name": "a.pdf", "is_folder": false}},
"all_count": float64(1),
}},
})
ctx := &ConvertContext{RawContent: `{"file_key":"fld_root","file_name":"Docs"}`, MessageID: "om_123", Runtime: rt}
got := (folderConverter{}).Convert(ctx)
if !strings.Contains(got, `<folder key="fld_root" name="Docs" child_count="1">`) || !strings.Contains(got, `<file key="f1" name="a.pdf"/>`) {
t.Fatalf("Convert() inline = %q, want expanded XML", got)
}
}
// Convert-level fallback: GET failure (no stub) -> single-line tag + stderr warning.
func TestFolderConverter_ConvertInlineFallback(t *testing.T) {
rt, _ := folderTestRuntime(t)
ctx := &ConvertContext{RawContent: `{"file_key":"fld_root","file_name":"Docs"}`, MessageID: "om_123", Runtime: rt}
got := (folderConverter{}).Convert(ctx)
want := `<folder key="fld_root" name="Docs"/>`
if got != want {
t.Fatalf("Convert() fallback = %q, want %q", got, want)
}
if errOut := rt.IO().ErrOut.(*bytes.Buffer).String(); !strings.Contains(errOut, "folder_fetch_failed") {
t.Fatalf("Convert() failure stderr = %q, want folder_fetch_failed warning", errOut)
}
}
// Post message with a folder attachment expands one level (same fetch path as
// folder messages) — regression guard for renderPostAttachments.
func TestPostConverter_FolderAttachmentExpansion(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/im/v1/files/fld_att/folder?recursive=false&srcid=om_p&srctype=message",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"file_key": "c1", "name": "sub.png", "is_folder": false}},
"all_count": float64(1),
}},
})
post := `{"zh_cn":{"text":"hi"},"files":[{"file_key":"fld_att","file_name":"assets","is_folder":true},{"file_key":"f1","file_name":"a.pdf"}]}`
ctx := &ConvertContext{RawContent: post, MessageID: "om_p", Runtime: rt}
got := (postConverter{}).Convert(ctx)
if !strings.Contains(got, `<folder key="fld_att" name="assets" child_count="1">`) ||
!strings.Contains(got, `<file key="c1" name="sub.png"/>`) ||
!strings.Contains(got, `<file key="f1" name="a.pdf"/>`) {
t.Fatalf("post Convert() = %q, want expanded folder attachment + file lines", got)
}
}
// Transport/business failures must emit the stable folder_fetch_failed warning
// prefix on stderr (regression guard for the warning side effect).
func TestFolderChildrenTree_WarningOnStderr(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{Method: "GET", URL: folderURL, Body: map[string]interface{}{"code": 14009, "msg": "dlp deny"}})
fetchFolderChildrenTree(rt, "fld_root", "x", "om_123")
errOut := rt.IO().ErrOut.(*bytes.Buffer).String()
// stable warning prefix; the payload (err text or code) may vary by failure path
if !strings.Contains(errOut, "folder_fetch_failed: fld_root:") {
t.Fatalf("stderr = %q, want folder_fetch_failed warning for fld_root", errOut)
}
}
// Prefetch failure sentinel (""): Convert degrades to the single-line tag and
// does NOT issue an inline retry (no stderr warning, no extra GET) — prevents
// page-wide failures (missing scope / DLP) from doubling every folder request.
func TestFolderConverter_PrefetchFailureSkipsInlineRetry(t *testing.T) {
rt, _ := folderTestRuntime(t)
ctx := &ConvertContext{
RawContent: `{"file_key":"fld_root","file_name":"Docs"}`,
MessageID: "om_fail",
Runtime: rt,
FolderChildren: map[string]string{"om_fail": ""}, // prefetch tried & failed
}
got := (folderConverter{}).Convert(ctx)
if got != `<folder key="fld_root" name="Docs"/>` {
t.Fatalf("Convert() failure sentinel = %q, want single-line degrade", got)
}
if errOut := rt.IO().ErrOut.(*bytes.Buffer).String(); errOut != "" {
t.Fatalf("Convert() sentinel stderr = %q, want empty (no inline retry)", errOut)
}
}
// PrefetchFolderChildren records failures as "" sentinels (not absent keys), so
// the render loop never retries inline after a failed prefetch.
func TestPrefetchFolderChildren_FailureSentinel(t *testing.T) {
rt := newBotConvertlibRuntime(t, folderChildrenRoundTrip(true)) // every fetch fails
rawItems := []interface{}{
map[string]interface{}{"msg_type": "folder", "message_id": "om_f1",
"body": map[string]interface{}{"content": `{"file_key":"fld_f1","file_name":"d1"}`}},
map[string]interface{}{"msg_type": "folder", "message_id": "om_f2",
"body": map[string]interface{}{"content": `{"file_key":"fld_f2","file_name":"d2"}`}},
}
cache := PrefetchFolderChildren(rt, rawItems)
if cache["om_f1"] != "" || cache["om_f2"] != "" {
t.Fatalf("PrefetchFolderChildren() failures = %#v, want both \"\" sentinels", cache)
}
errOut := rt.IO().ErrOut.(*bytes.Buffer).String()
if strings.Count(errOut, "folder_fetch_failed") != 2 {
t.Fatalf("stderr warnings = %q, want 2 folder_fetch_failed (one per prefetch)", errOut)
}
}
// Post messages with folder attachments are picked up by PrefetchFolderChildren
// (cache key message_id + folder key); the post converter reuses the cached XML
// instead of an inline GET.
func TestPostFolderAttachmentPrefetch(t *testing.T) {
rt := newBotConvertlibRuntime(t, folderChildrenRoundTrip(false))
raw := map[string]interface{}{
"msg_type": "post", "message_id": "om_p1",
"body": map[string]interface{}{"content": `{"zh_cn":{"text":"hi"},"files":[{"file_key":"fld_p1","file_name":"assets","is_folder":true}]}`},
}
cache := PrefetchFolderChildren(rt, []interface{}{raw})
xml, ok := cache["om_p1\x00fld_p1"]
if !ok || !strings.Contains(xml, `<folder key="fld_p1" name="assets" child_count="1">`) {
t.Fatalf("PrefetchFolderChildren() post attachment = %q (ok=%v), want cached expansion", xml, ok)
}
// Render via postConverter — should reuse the cache (stub would only answer once).
ctx := &ConvertContext{RawContent: raw["body"].(map[string]interface{})["content"].(string), MessageID: "om_p1", Runtime: rt, FolderChildren: cache}
got := (postConverter{}).Convert(ctx)
if !strings.Contains(got, `<folder key="fld_p1" name="assets" child_count="1">`) || !strings.Contains(got, `<file key="f1" name="a.pdf"/>`) {
t.Fatalf("post Convert() with prefetch = %q, want cached folder expansion", got)
}
}
// Server returns all_count > 0 but no items -> self-closing folder tag with
// child_count + has_more (no odd empty open/close pair).
func TestFetchFolderChildrenTree_NoItemsButAllCount(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: folderURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}, "all_count": float64(3)}},
})
got := fetchFolderChildrenTree(rt, "fld_root", "x", "om_123")
want := `<folder key="fld_root" name="x" child_count="3" has_more="true"/>`
if got != want {
t.Fatalf("fetchFolderChildrenTree() no-items-all-count = %q, want %q", got, want)
}
}
// code:0 without a data field -> "" (degrade) plus an "empty data" stderr
// warning (not a misleading "<nil>").
func TestFetchFolderChildrenTree_EmptyData(t *testing.T) {
rt, reg := folderTestRuntime(t)
reg.Register(&httpmock.Stub{Method: "GET", URL: folderURL, Body: map[string]interface{}{"code": 0}})
got := fetchFolderChildrenTree(rt, "fld_root", "x", "om_123")
if got != "" {
t.Fatalf("fetchFolderChildrenTree() empty data = %q, want empty", got)
}
errOut := rt.IO().ErrOut.(*bytes.Buffer).String()
if !strings.Contains(errOut, "folder_fetch_failed: fld_root: empty data") {
t.Fatalf("stderr = %q, want 'empty data' warning", errOut)
}
}