Skip to content

Commit dbb5acb

Browse files
test: strengthen doc fetch error coverage
1 parent c882023 commit dbb5acb

3 files changed

Lines changed: 241 additions & 24 deletions

File tree

shortcuts/doc/docs_fetch_im_markdown_test.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,202 @@ func TestNewIMMarkdownContextExtractsBaseURL(t *testing.T) {
10831083
}
10841084
}
10851085

1086+
func TestIMMarkdownBaseURLFromInputEdges(t *testing.T) {
1087+
t.Parallel()
1088+
1089+
tests := []struct {
1090+
name string
1091+
input string
1092+
want string
1093+
ok bool
1094+
}{
1095+
{
1096+
name: "empty candidate before marker is skipped",
1097+
input: "///docx/doc_token",
1098+
},
1099+
{
1100+
name: "scheme candidate before marker is returned",
1101+
input: "//https://tenant.example.com/docx/doc_token",
1102+
want: "https://tenant.example.com",
1103+
ok: true,
1104+
},
1105+
{
1106+
name: "host without dot before marker is rejected",
1107+
input: "tenant/docx/doc_token",
1108+
},
1109+
{
1110+
name: "no document marker is rejected",
1111+
input: "tenant.example.com/path/doc_token",
1112+
},
1113+
}
1114+
1115+
for _, tt := range tests {
1116+
t.Run(tt.name, func(t *testing.T) {
1117+
t.Parallel()
1118+
1119+
got, ok := imMarkdownBaseURLFromInput(tt.input)
1120+
if ok != tt.ok {
1121+
t.Fatalf("ok = %v, want %v", ok, tt.ok)
1122+
}
1123+
if got != tt.want {
1124+
t.Fatalf("baseURL = %q, want %q", got, tt.want)
1125+
}
1126+
})
1127+
}
1128+
}
1129+
1130+
func TestIMMarkdownHandlerDirectFallbackBranches(t *testing.T) {
1131+
t.Parallel()
1132+
1133+
ctx := imMarkdownContext{baseURL: "https://tenant.example.com"}
1134+
cases := []struct {
1135+
name string
1136+
got string
1137+
want string
1138+
}{
1139+
{
1140+
name: "empty heading",
1141+
got: handleIMMarkdownHeading(2)("", " ", nil, ctx),
1142+
want: "",
1143+
},
1144+
{
1145+
name: "empty paragraph",
1146+
got: handleIMMarkdownParagraph("", " ", nil, ctx),
1147+
want: "",
1148+
},
1149+
{
1150+
name: "list item seq trims trailing dot",
1151+
got: handleIMMarkdownListItem("", "first\nsecond", map[string]string{"seq": "7."}, ctx),
1152+
want: "7. first\n second\n",
1153+
},
1154+
{
1155+
name: "list item seq auto uses bullet",
1156+
got: handleIMMarkdownListItem("", "first", map[string]string{"seq": "auto"}, ctx),
1157+
want: "- first\n",
1158+
},
1159+
{
1160+
name: "empty list item",
1161+
got: handleIMMarkdownListItem("", " ", map[string]string{"seq": "3"}, ctx),
1162+
want: "",
1163+
},
1164+
{
1165+
name: "empty callout",
1166+
got: handleIMMarkdownCallout("", "", nil, ctx),
1167+
want: "---\n---",
1168+
},
1169+
{
1170+
name: "empty blockquote",
1171+
got: handleIMMarkdownBlockquote("", " ", nil, ctx),
1172+
want: "",
1173+
},
1174+
{
1175+
name: "blockquote preserves blank quote lines",
1176+
got: handleIMMarkdownBlockquote("", "first\n\nsecond", nil, ctx),
1177+
want: "> first\n>\n> second",
1178+
},
1179+
{
1180+
name: "empty latex",
1181+
got: handleIMMarkdownLatex("", " ", nil, ctx),
1182+
want: "",
1183+
},
1184+
{
1185+
name: "image without URL",
1186+
got: handleIMMarkdownImage("", "", map[string]string{"alt": "A"}, ctx),
1187+
want: "",
1188+
},
1189+
{
1190+
name: "empty strong",
1191+
got: handleIMMarkdownStrong("", " ", nil, ctx),
1192+
want: "",
1193+
},
1194+
{
1195+
name: "empty emphasis",
1196+
got: handleIMMarkdownEmphasis("", " ", nil, ctx),
1197+
want: "",
1198+
},
1199+
{
1200+
name: "empty delete",
1201+
got: handleIMMarkdownDelete("", " ", nil, ctx),
1202+
want: "",
1203+
},
1204+
{
1205+
name: "anchor without href",
1206+
got: handleIMMarkdownAnchor("", "<b>plain</b>", nil, ctx),
1207+
want: "**plain**",
1208+
},
1209+
{
1210+
name: "table skips rows without cells",
1211+
got: handleIMMarkdownTable("<table><tr></tr></table>", "<tr></tr>", nil, ctx),
1212+
want: "`<table><tr></tr></table>`",
1213+
},
1214+
{
1215+
name: "empty normalized table cell",
1216+
got: normalizeIMMarkdownTableCell("<span> </span>"),
1217+
want: "",
1218+
},
1219+
{
1220+
name: "plain fenced code uses minimum fence",
1221+
got: imMarkdownFencedCode("plain", ""),
1222+
want: "```\nplain\n```",
1223+
},
1224+
}
1225+
1226+
for _, tt := range cases {
1227+
t.Run(tt.name, func(t *testing.T) {
1228+
t.Parallel()
1229+
1230+
if tt.got != tt.want {
1231+
t.Fatalf("got %q, want %q", tt.got, tt.want)
1232+
}
1233+
})
1234+
}
1235+
}
1236+
1237+
func TestIMMarkdownExtractionAndListBreakBranches(t *testing.T) {
1238+
t.Parallel()
1239+
1240+
rowBodies := extractIMMarkdownElementBodies(`</tr><tr/><tr>open`, imMarkdownRowTagRE)
1241+
if want := []string{""}; !reflect.DeepEqual(rowBodies, want) {
1242+
t.Fatalf("extractIMMarkdownElementBodies() = %#v, want %#v", rowBodies, want)
1243+
}
1244+
1245+
if _, _, ok := findIMMarkdownElementClosingTag(`<tr><td>open</td>`, len("<tr>"), imMarkdownRowTagRE); ok {
1246+
t.Fatal("findIMMarkdownElementClosingTag() found closing tag, want false")
1247+
}
1248+
1249+
if got := convertIMMarkdownListItems("", false, imMarkdownContext{}); got != "" {
1250+
t.Fatalf("empty list conversion = %q, want empty", got)
1251+
}
1252+
if got := convertIMMarkdownListItems("<li>open", false, imMarkdownContext{}); got != "" {
1253+
t.Fatalf("unclosed list conversion = %q, want empty", got)
1254+
}
1255+
if _, _, ok := findIMMarkdownListItemClosingTag(`<li>outer<li>inner</li>`, len("<li>")); ok {
1256+
t.Fatal("findIMMarkdownListItemClosingTag() found closing tag for unbalanced nested item")
1257+
}
1258+
}
1259+
1260+
func TestIMMarkdownLinkAndEncodingFallbackBranches(t *testing.T) {
1261+
t.Parallel()
1262+
1263+
text, href, ok := extractIMMarkdownInnerLink(`<a href='https://example.com/ref'></a>`)
1264+
if !ok {
1265+
t.Fatal("extractIMMarkdownInnerLink() ok = false, want true")
1266+
}
1267+
if text != "https://example.com/ref" || href != "https://example.com/ref" {
1268+
t.Fatalf("inner link = (%q, %q), want href fallback", text, href)
1269+
}
1270+
1271+
if got := escapeMarkdownLinkDestination("a%zz%"); got != "a%25zz%25" {
1272+
t.Fatalf("escaped invalid percent = %q, want %q", got, "a%25zz%25")
1273+
}
1274+
if got := escapeMarkdownLinkDestination("研发"); got != "%E7%A0%94%E5%8F%91" {
1275+
t.Fatalf("escaped unicode = %q, want encoded UTF-8 bytes", got)
1276+
}
1277+
if got := escapeMarkdownLinkDestination(string([]byte{'a', 0xff, 'b'})); got != "a%FFb" {
1278+
t.Fatalf("escaped invalid UTF-8 = %q, want %q", got, "a%FFb")
1279+
}
1280+
}
1281+
10861282
type imMarkdownCase struct {
10871283
name string
10881284
input string

shortcuts/doc/docs_fetch_v2_test.go

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ package doc
66
import (
77
"context"
88
"encoding/json"
9+
"errors"
910
"reflect"
1011
"strings"
1112
"testing"
1213

14+
"github.com/larksuite/cli/errs"
1315
"github.com/larksuite/cli/internal/cmdutil"
1416
"github.com/larksuite/cli/internal/core"
1517
"github.com/larksuite/cli/internal/httpmock"
@@ -252,9 +254,10 @@ func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
252254
t.Parallel()
253255

254256
tests := []struct {
255-
name string
256-
setFlags map[string]string
257-
wantParam string
257+
name string
258+
setFlags map[string]string
259+
wantParam string
260+
wantParams []string
258261
}{
259262
{
260263
name: "negative context before",
@@ -288,7 +291,10 @@ func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
288291
setFlags: map[string]string{
289292
"scope": "range",
290293
},
291-
wantParam: "--start-block-id",
294+
wantParams: []string{
295+
"--start-block-id",
296+
"--end-block-id",
297+
},
292298
},
293299
{
294300
name: "keyword needs keyword",
@@ -326,9 +332,7 @@ func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
326332
if err == nil {
327333
t.Fatal("validateReadModeFlags() succeeded, want error")
328334
}
329-
if !strings.Contains(err.Error(), tt.wantParam) {
330-
t.Fatalf("error = %v, want param %s", err, tt.wantParam)
331-
}
335+
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam, tt.wantParams...)
332336
})
333337
}
334338
}
@@ -389,23 +393,23 @@ func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) {
389393
t.Parallel()
390394

391395
tests := []struct {
392-
name string
393-
setFlags map[string]string
394-
want string
396+
name string
397+
setFlags map[string]string
398+
wantParam string
395399
}{
396400
{
397401
name: "invalid doc",
398402
setFlags: map[string]string{
399403
"doc": "https://example.com/sheets/sht_token",
400404
},
401-
want: "--doc",
405+
wantParam: "--doc",
402406
},
403407
{
404408
name: "invalid scope",
405409
setFlags: map[string]string{
406410
"scope": "bad",
407411
},
408-
want: "--scope",
412+
wantParam: "--scope",
409413
},
410414
}
411415

@@ -418,9 +422,7 @@ func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) {
418422
if err == nil {
419423
t.Fatal("validateFetchV2() succeeded, want error")
420424
}
421-
if !strings.Contains(err.Error(), tt.want) {
422-
t.Fatalf("error = %v, want %s", err, tt.want)
423-
}
425+
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam)
424426
})
425427
}
426428
}
@@ -651,7 +653,7 @@ func TestDocsFetchV2ReturnsAPIError(t *testing.T) {
651653
Method: "POST",
652654
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchAPIError/fetch",
653655
Body: map[string]interface{}{
654-
"code": 99991663,
656+
"code": 999999,
655657
"msg": "fetch failed",
656658
},
657659
})
@@ -664,8 +666,28 @@ func TestDocsFetchV2ReturnsAPIError(t *testing.T) {
664666
if err == nil {
665667
t.Fatal("mountAndRunDocs() succeeded, want API error")
666668
}
667-
if !strings.Contains(err.Error(), "fetch failed") {
668-
t.Fatalf("error = %v, want API message", err)
669+
var apiErr *errs.APIError
670+
if !errors.As(err, &apiErr) {
671+
t.Fatalf("error type = %T, want *errs.APIError (%v)", err, err)
672+
}
673+
p, ok := errs.ProblemOf(err)
674+
if !ok {
675+
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
676+
}
677+
if p.Category != errs.CategoryAPI {
678+
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
679+
}
680+
if p.Subtype != errs.SubtypeUnknown {
681+
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
682+
}
683+
if p.Code != 999999 {
684+
t.Errorf("code = %d, want 999999", p.Code)
685+
}
686+
if p.Message != "fetch failed" {
687+
t.Errorf("message = %q, want %q", p.Message, "fetch failed")
688+
}
689+
if cause := errors.Unwrap(err); cause != nil {
690+
t.Fatalf("unexpected wrapped cause for API response error: %T %v", cause, cause)
669691
}
670692
}
671693

@@ -754,6 +776,7 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
754776
if err == nil {
755777
t.Fatal("expected v2-only validation error")
756778
}
779+
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--offset")
757780
for _, want := range tt.want {
758781
if !strings.Contains(err.Error(), want) {
759782
t.Fatalf("error missing %q: %v", want, err)

skills/lark-doc/SKILL.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
2626
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
2727
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2828
2. **读取文档(`docs +fetch --api-version v2`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` / `--doc-format` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
29-
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
29+
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md)[`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
3030
4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。
3131

3232
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
@@ -36,11 +36,9 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
3636
> - **精准编辑场景**`docs +update``str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
3737
3838
## 快速决策
39-
- 用户需要“某个 block 的直达链接 / 锚点链接”时:返回 `文档基础 URL#block_id`。如果当前只有文档 URL 没有 block_id,先用 `docs +fetch --detail with-ids` 拿到目标 block 的 id
40-
- 例:
41-
- 已知文档 URL = `https://xxx.feishu.cn/docx/doxcn123`
42-
- 已知 block_id = `blkcn456`
43-
- 应返回 `https://xxx.feishu.cn/docx/doxcn123#blkcn456`
39+
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
40+
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
41+
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
4442
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
4543
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
4644
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入

0 commit comments

Comments
 (0)