// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package drive import ( "encoding/json" "errors" "strings" "testing" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" ) // assertContentValidationHint asserts err is a typed *errs.ValidationError // carrying SubtypeInvalidArgument, Param "--content", and a Hint containing // the given substring. The over-cap message flows through a typed // ValidationError. func assertContentValidationHint(t *testing.T, err error, wantHint string) { t.Helper() var valErr *errs.ValidationError if !errors.As(err, &valErr) { t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err) } if valErr.Subtype != errs.SubtypeInvalidArgument { t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument) } if valErr.Param != "--content" { t.Errorf("Param = %q, want %q", valErr.Param, "--content") } if !strings.Contains(valErr.Hint, wantHint) { t.Errorf("expected hint substring %q, got %q", wantHint, valErr.Hint) } } func decodeJSONMap(t *testing.T, raw string) map[string]interface{} { t.Helper() var data map[string]interface{} if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("failed to decode JSON output: %v\nstdout:\n%s", err, raw) } return data } func mustMapValue(t *testing.T, value interface{}, path string) map[string]interface{} { t.Helper() got, ok := value.(map[string]interface{}) if !ok { t.Fatalf("%s is %T, want map[string]interface{}", path, value) } return got } func mustSliceValue(t *testing.T, value interface{}, path string) []interface{} { t.Helper() got, ok := value.([]interface{}) if !ok { t.Fatalf("%s is %T, want []interface{}", path, value) } return got } func mustStringField(t *testing.T, m map[string]interface{}, key, path string) string { t.Helper() got, ok := m[key].(string) if !ok { t.Fatalf("%s is %T, want string", path, m[key]) } return got } func TestParseCommentDocRef(t *testing.T) { t.Parallel() tests := []struct { name string input string docType string wantKind string wantToken string wantErr string }{ { name: "docx url", input: "https://example.larksuite.com/docx/xxxxxx?from=wiki", wantKind: "docx", wantToken: "xxxxxx", }, { name: "wiki url", input: "https://example.larksuite.com/wiki/xxxxxx", wantKind: "wiki", wantToken: "xxxxxx", }, { name: "raw token with type docx", input: "xxxxxx", docType: "docx", wantKind: "docx", wantToken: "xxxxxx", }, { name: "raw token with type sheet", input: "shtToken", docType: "sheet", wantKind: "sheet", wantToken: "shtToken", }, { name: "raw token with type slides", input: "slideToken", docType: "slides", wantKind: "slides", wantToken: "slideToken", }, { name: "raw token with type doc", input: "docToken", docType: "doc", wantKind: "doc", wantToken: "docToken", }, { name: "raw token with type file", input: "fileToken", docType: "file", wantKind: "file", wantToken: "fileToken", }, { name: "raw token with type bitable", input: "baseToken", docType: "bitable", wantKind: "base", wantToken: "baseToken", }, { name: "raw token with type base alias", input: "baseToken", docType: "base", wantKind: "base", wantToken: "baseToken", }, { name: "raw token without type", input: "xxxxxx", wantErr: "--type is required", }, { name: "old doc url", input: "https://example.larksuite.com/doc/xxxxxx", wantKind: "doc", wantToken: "xxxxxx", }, { name: "slides url", input: "https://example.larksuite.com/slides/pres_123?from=share", wantKind: "slides", wantToken: "pres_123", }, { name: "file url", input: "https://example.larksuite.com/file/boxcn123?from=share", wantKind: "file", wantToken: "boxcn123", }, { name: "base url", input: "https://example.larksuite.com/base/baseToken123?table=tbl1", wantKind: "base", wantToken: "baseToken123", }, { name: "bitable url", input: "https://example.larksuite.com/bitable/baseToken456?table=tbl1", wantKind: "base", wantToken: "baseToken456", }, { name: "unsupported url", input: "https://example.com/not-a-doc", wantErr: "unsupported --doc input", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := parseCommentDocRef(tt.input, tt.docType) if tt.wantErr != "" { if err == nil { t.Fatalf("expected error containing %q, got nil", tt.wantErr) } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got.Kind != tt.wantKind { t.Fatalf("kind mismatch: want %q, got %q", tt.wantKind, got.Kind) } if got.Token != tt.wantToken { t.Fatalf("token mismatch: want %q, got %q", tt.wantToken, got.Token) } }) } } func TestResolveCommentMode(t *testing.T) { t.Parallel() tests := []struct { name string explicitFull bool selection string blockID string want commentMode }{ { name: "explicit full comment", explicitFull: true, want: commentModeFull, }, { name: "auto full comment without anchor", explicitFull: false, want: commentModeFull, }, { name: "selection means local comment", selection: "流程", want: commentModeLocal, }, { name: "block id means local comment", blockID: "blk_123", want: commentModeLocal, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := resolveCommentMode(tt.explicitFull, tt.selection, tt.blockID) if got != tt.want { t.Fatalf("mode mismatch: want %q, got %q", tt.want, got) } }) } } func TestSelectLocateMatch(t *testing.T) { t.Parallel() result := locateDocResult{ MatchCount: 2, Matches: []locateDocMatch{ { AnchorBlockID: "blk_1", Blocks: []locateDocBlock{ {BlockID: "blk_1", RawMarkdown: "流程\n"}, }, }, { AnchorBlockID: "blk_2", Blocks: []locateDocBlock{ {BlockID: "blk_2", RawMarkdown: "流程图\n"}, }, }, }, } _, _, err := selectLocateMatch(result) if err == nil || !strings.Contains(err.Error(), "matched 2 blocks") { t.Fatalf("expected ambiguous match error, got %v", err) } if strings.Contains(err.Error(), "流程") || strings.Contains(err.Error(), "流程图") { t.Fatalf("ambiguous match error should not leak locate-doc snippets: %v", err) } if !strings.Contains(err.Error(), "anchor_block_id=blk_1") || !strings.Contains(err.Error(), "anchor_block_id=blk_2") { t.Fatalf("ambiguous match error should keep anchor block identifiers: %v", err) } } func TestParseLocateDocResultFallsBackToFirstBlock(t *testing.T) { t.Parallel() got := parseLocateDocResult(map[string]interface{}{ "match_count": float64(1), "matches": []interface{}{ map[string]interface{}{ "blocks": []interface{}{ map[string]interface{}{ "block_id": "blk_anchor", "raw_markdown": "流程\n", }, }, }, }, }) if len(got.Matches) != 1 { t.Fatalf("expected 1 match, got %d", len(got.Matches)) } if got.Matches[0].AnchorBlockID != "blk_anchor" { t.Fatalf("expected fallback anchor block, got %q", got.Matches[0].AnchorBlockID) } } func TestParseCommentReplyElements(t *testing.T) { t.Parallel() got, err := parseCommentReplyElements(`[{"type":"text","text":"文本信息"},{"type":"mention_user","text":"ou_123"},{"type":"link","text":"https://example.com"}]`) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(got) != 3 { t.Fatalf("expected 3 reply elements, got %d", len(got)) } if got[0]["type"] != "text" || got[0]["text"] != "文本信息" { t.Fatalf("unexpected text reply element: %#v", got[0]) } if got[1]["type"] != "mention_user" || got[1]["mention_user"] != "ou_123" { t.Fatalf("unexpected mention_user reply element: %#v", got[1]) } if got[2]["type"] != "link" || got[2]["link"] != "https://example.com" { t.Fatalf("unexpected link reply element: %#v", got[2]) } } func TestParseCommentReplyElementsEscapesAngleBrackets(t *testing.T) { t.Parallel() got, err := parseCommentReplyElements(`[{"type":"text","text":"a < b > c"}]`) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(got) != 1 { t.Fatalf("expected 1 reply element, got %d", len(got)) } if got[0]["text"] != "a < b > c" { t.Fatalf("expected escaped text, got %#v", got[0]["text"]) } } func TestParseCommentReplyElementsTextLength(t *testing.T) { t.Parallel() // Cap is 10000 runes total across all reply_elements text fields, // empirically derived from the live API. See the comment on // maxCommentTotalRunes for the probe results. exactCapASCII := strings.Repeat("a", 10000) overCapASCII := strings.Repeat("a", 10001) // Chinese chars cost 3 bytes each in UTF-8 but the server counts // runes, not bytes — so the cap is the same 10000 here. exactCapCJK := strings.Repeat("文", 10000) overCapCJK := strings.Repeat("文", 10001) // '<' would expand to '<' (4 bytes) under escapeCommentText, but // since the server counts raw runes the cap is still 10000 chars, // not 2500. This pins that distinction. exactCapAngle := strings.Repeat("<", 10000) overCapAngle := strings.Repeat("<", 10001) // Two-element split exactly hitting the cap together. splitFiveK := strings.Repeat("a", 5000) splitFiveKPlusOne := strings.Repeat("a", 5001) tests := []struct { name string input string wantErr string wantHint string // substring of the hint portion; "" means don't check hint wantCount int // expected parsed element count when no error expected }{ { name: "single element exactly at 10000 ASCII chars accepted", input: `[{"type":"text","text":"` + exactCapASCII + `"}]`, wantCount: 1, }, { name: "single element at 10001 ASCII chars rejected", input: `[{"type":"text","text":"` + overCapASCII + `"}]`, wantErr: "totals 10001 characters at element #1", wantHint: "splitting one long element into multiple smaller text elements does NOT help", }, { name: "single element exactly at 10000 chinese chars accepted (server counts runes, not bytes)", input: `[{"type":"text","text":"` + exactCapCJK + `"}]`, wantCount: 1, }, { name: "single element at 10001 chinese chars rejected", input: `[{"type":"text","text":"` + overCapCJK + `"}]`, wantErr: "totals 10001 characters at element #1", }, { name: "10000 angle brackets accepted (server counts raw runes, not escaped form)", input: `[{"type":"text","text":"` + exactCapAngle + `"}]`, wantCount: 1, }, { name: "10001 angle brackets rejected (escape state irrelevant to cap)", input: `[{"type":"text","text":"` + overCapAngle + `"}]`, wantErr: "totals 10001 characters at element #1", }, { // Pins the multi-element TOTAL cap: two 5000-char elements // fit together exactly (10000 sum). This is the boundary the // previous PR's "split into multiple elements" advice // implied was a workaround — it's actually only valid if // the sum still fits. name: "two elements totalling exactly 10000 accepted", input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveK + `"}]`, wantCount: 2, }, { // Companion to the above and the headline reason the prior // "split into multiple elements" hint is wrong: 5000+5001 // sums to 10001 which the server rejects with the same // opaque [1069302], regardless of how many elements it's // distributed across. name: "two elements totalling 10001 rejected with index pointing at offending element", input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveKPlusOne + `"}]`, wantErr: "totals 10001 characters at element #2", wantHint: "splitting one long element into multiple smaller text elements does NOT help", }, { // Streaming-cap correctness: when an EARLY element by itself // already overshoots, the index reported is that early // element (not the last one in the array). name: "first element over the cap reports index 1", input: `[{"type":"text","text":"` + overCapASCII + `"},{"type":"text","text":"trailing"}]`, wantErr: "totals 10001 characters at element #1", }, { // mention_user / link elements don't count toward the // rune cap (their content is ID / URL, not user-visible // running text). Pin that a moderate text plus a mention // stays accepted even though the mention adds bytes. name: "text plus mention_user does not double-count toward cap", input: `[{"type":"text","text":"` + exactCapASCII + `"},{"type":"mention_user","text":"ou_1234567890abcdef"}]`, wantCount: 2, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := parseCommentReplyElements(tt.input) if tt.wantErr != "" { if err == nil { t.Fatalf("expected error containing %q, got nil (parsed %d elements)", tt.wantErr, len(got)) } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) } if tt.wantHint != "" { // Hint lives on the typed ValidationError, not err.Error(). assertContentValidationHint(t, err, tt.wantHint) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if len(got) != tt.wantCount { t.Fatalf("expected %d reply elements, got %d", tt.wantCount, len(got)) } }) } } // TestParseCommentReplyElementsHintForbidsSplitAdvice pins that the // over-cap hint does NOT recommend splitting into multiple text // elements as a workaround. An earlier version of this PR shipped // that advice; live-API probing showed the cap is on the *total* run // of characters across all reply_elements, so splitting doesn't // bypass it. If the hint ever drifts back into recommending a split, // users will be sent down a dead end where their first attempt fails // pre-flight, their "fixed" attempt also fails server-side, and // they're stuck. func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) { t.Parallel() _, err := parseCommentReplyElements(`[{"type":"text","text":"` + strings.Repeat("a", 10001) + `"}]`) if err == nil { t.Fatal("expected over-cap error, got nil") } var valErr *errs.ValidationError if !errors.As(err, &valErr) { t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err) } hint := valErr.Hint // The hint must explicitly call out that splitting does NOT help. if !strings.Contains(hint, "does NOT help") { t.Errorf("hint must explicitly say splitting does NOT help, got: %q", hint) } // Anti-pattern check: the hint must not phrase any "split into // multiple elements" recommendation as a workaround. Look for the // previous PR's exact phrasing variants. for _, banned := range []string{ "split the content across multiple", "split into multiple text elements", "renders them as one contiguous comment", } { if strings.Contains(hint, banned) { t.Errorf("hint must not contain the discredited %q advice, got: %q", banned, hint) } } // And it should reference the actual number so callers know the // budget without having to read the source. if !strings.Contains(hint, "10000") { t.Errorf("hint should name the 10000-rune budget, got: %q", hint) } } func TestParseCommentReplyElementsInvalid(t *testing.T) { t.Parallel() tests := []struct { name string input string wantErr string }{ { name: "invalid json", input: `[{"type":"text","text":"x"}`, wantErr: "--content is not valid JSON", }, { name: "empty array", input: `[]`, wantErr: "must contain at least one reply element", }, { name: "unsupported type", input: `[{"type":"image","text":"x"}]`, wantErr: "unsupported type", }, { name: "mention missing value", input: `[{"type":"mention_user","text":""}]`, wantErr: "requires text or mention_user", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() if _, err := parseCommentReplyElements(tt.input); err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) } }) } } func TestBuildCommentCreateV2RequestFull(t *testing.T) { t.Parallel() replyElements := []map[string]interface{}{ { "type": "text", "text": "全文评论", }, } got := buildCommentCreateV2Request("docx", "", "", replyElements, nil) if got["file_type"] != "docx" { t.Fatalf("expected file_type docx, got %#v", got["file_type"]) } if _, ok := got["anchor"]; ok { t.Fatalf("expected no anchor for full comment, got %#v", got["anchor"]) } gotReplyElements, ok := got["reply_elements"].([]map[string]interface{}) if !ok || len(gotReplyElements) != 1 { t.Fatalf("expected one reply element, got %#v", got["reply_elements"]) } if gotReplyElements[0]["type"] != "text" { t.Fatalf("expected text element, got %#v", gotReplyElements[0]["type"]) } if gotReplyElements[0]["text"] != "全文评论" { t.Fatalf("expected text %q, got %#v", "全文评论", gotReplyElements[0]["text"]) } } func TestBuildCommentCreateV2RequestFile(t *testing.T) { t.Parallel() replyElements := []map[string]interface{}{ { "type": "text", "text": "README comment", }, } got := buildCommentCreateV2Request("file", "", "", replyElements, nil) if got["file_type"] != "file" { t.Fatalf("expected file_type file, got %#v", got["file_type"]) } anchor, ok := got["anchor"].(map[string]interface{}) if !ok { t.Fatalf("expected anchor map, got %#v", got["anchor"]) } if blockID, ok := anchor["block_id"].(string); !ok || blockID != fileFullCommentAnchorBlockID { t.Fatalf("expected file anchor.block_id %q, got %#v", fileFullCommentAnchorBlockID, anchor["block_id"]) } } func TestBuildCommentCreateV2RequestLocal(t *testing.T) { t.Parallel() replyElements := []map[string]interface{}{ { "type": "text", "text": "评论内容", }, } got := buildCommentCreateV2Request("docx", "blk_123", "", replyElements, nil) if got["file_type"] != "docx" { t.Fatalf("expected file_type docx, got %#v", got["file_type"]) } anchor, ok := got["anchor"].(map[string]interface{}) if !ok { t.Fatalf("expected anchor map, got %#v", got["anchor"]) } if anchor["block_id"] != "blk_123" { t.Fatalf("expected block_id blk_123, got %#v", anchor["block_id"]) } gotReplyElements, ok := got["reply_elements"].([]map[string]interface{}) if !ok || len(gotReplyElements) != 1 { t.Fatalf("expected one reply element, got %#v", got["reply_elements"]) } if gotReplyElements[0]["type"] != "text" || gotReplyElements[0]["text"] != "评论内容" { t.Fatalf("unexpected reply element: %#v", gotReplyElements[0]) } } func TestBuildCommentCreateV2RequestSlides(t *testing.T) { t.Parallel() replyElements := []map[string]interface{}{ { "type": "text", "text": "slide comment", }, } got := buildCommentCreateV2Request("slides", "shape_123", "shape", replyElements, nil) if got["file_type"] != "slides" { t.Fatalf("expected file_type slides, got %#v", got["file_type"]) } anchor, ok := got["anchor"].(map[string]interface{}) if !ok { t.Fatalf("expected anchor map, got %#v", got["anchor"]) } if anchor["block_id"] != "shape_123" { t.Fatalf("expected block_id shape_123, got %#v", anchor["block_id"]) } if anchor["slide_block_type"] != "shape" { t.Fatalf("expected slide_block_type shape, got %#v", anchor["slide_block_type"]) } } // ── Sheet comment tests ───────────────────────────────────────────────────── func TestParseCommentDocRefSheet(t *testing.T) { t.Parallel() ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123", "") if err != nil { t.Fatalf("unexpected error: %v", err) } if ref.Kind != "sheet" || ref.Token != "shtToken123" { t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token) } } func TestParseCommentDocRefSheetWithQuery(t *testing.T) { t.Parallel() ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123?sheet=abc", "") if err != nil { t.Fatalf("unexpected error: %v", err) } if ref.Kind != "sheet" || ref.Token != "shtToken123" { t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token) } } func TestBuildCommentCreateV2RequestSheet(t *testing.T) { t.Parallel() replyElements := []map[string]interface{}{ {"type": "text", "text": "请修正此单元格"}, } got := buildCommentCreateV2Request("sheet", "", "", replyElements, &sheetAnchor{ SheetID: "abc123", Col: 3, Row: 5, }) if got["file_type"] != "sheet" { t.Fatalf("expected file_type sheet, got %#v", got["file_type"]) } anchor, ok := got["anchor"].(map[string]interface{}) if !ok { t.Fatalf("expected anchor map, got %#v", got["anchor"]) } if anchor["block_id"] != "abc123" { t.Fatalf("expected block_id abc123, got %#v", anchor["block_id"]) } if anchor["sheet_col"] != 3 { t.Fatalf("expected sheet_col 3, got %#v", anchor["sheet_col"]) } if anchor["sheet_row"] != 5 { t.Fatalf("expected sheet_row 5, got %#v", anchor["sheet_row"]) } } func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) { t.Parallel() replyElements := []map[string]interface{}{ {"type": "text", "text": "test"}, } // When both sheet anchor and blockID are provided, sheet anchor wins. got := buildCommentCreateV2Request("sheet", "should_be_ignored", "", replyElements, &sheetAnchor{ SheetID: "s1", Col: 0, Row: 0, }) anchor := got["anchor"].(map[string]interface{}) if anchor["block_id"] != "s1" { t.Fatalf("expected sheet anchor block_id, got %#v", anchor["block_id"]) } if _, exists := anchor["sheet_col"]; !exists { t.Fatal("expected sheet_col in anchor") } } func TestBuildBaseCommentCreateV2Request(t *testing.T) { t.Parallel() replyElements := []map[string]interface{}{ {"type": "text", "text": "base comment"}, } got := buildBaseCommentCreateV2Request(replyElements, baseAnchor{ BlockID: "tbl9mp6fj9kDKHQV", BaseRecordID: "recBIBgGmb", BaseViewID: "vewc46MG1R", }) if got["file_type"] != "bitable" { t.Fatalf("expected file_type bitable, got %#v", got["file_type"]) } anchor, ok := got["anchor"].(map[string]interface{}) if !ok { t.Fatalf("expected anchor map, got %#v", got["anchor"]) } if anchor["block_id"] != "tbl9mp6fj9kDKHQV" { t.Fatalf("expected block_id tbl9mp6fj9kDKHQV, got %#v", anchor["block_id"]) } if anchor["base_record_id"] != "recBIBgGmb" { t.Fatalf("expected base_record_id recBIBgGmb, got %#v", anchor["base_record_id"]) } if anchor["base_view_id"] != "vewc46MG1R" { t.Fatalf("expected base_view_id vewc46MG1R, got %#v", anchor["base_view_id"]) } } // ── Sheet cell ref parsing tests ──────────────────────────────────────────── func TestParseSheetCellRef(t *testing.T) { t.Parallel() tests := []struct { name string input string sheetID string col int row int }{ {"A1", "s1!A1", "s1", 0, 0}, {"D6", "abc!D6", "abc", 3, 5}, {"AA1", "s1!AA1", "s1", 26, 0}, {"lowercase", "s1!d6", "s1", 3, 5}, {"B10", "sheet1!B10", "sheet1", 1, 9}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() got, err := parseSheetCellRef(tc.input) if err != nil { t.Fatalf("unexpected error: %v", err) } if got.SheetID != tc.sheetID || got.Col != tc.col || got.Row != tc.row { t.Fatalf("expected {%s %d %d}, got {%s %d %d}", tc.sheetID, tc.col, tc.row, got.SheetID, got.Col, got.Row) } }) } } func TestParseSheetCellRefInvalid(t *testing.T) { t.Parallel() cases := []string{"", "noExclamation", "s1!", "!A1", "s1!123", "s1!A"} for _, input := range cases { t.Run(input, func(t *testing.T) { t.Parallel() _, err := parseSheetCellRef(input) if err == nil { t.Fatalf("expected error for %q", input) } }) } } func TestParseSlidesBlockRef(t *testing.T) { t.Parallel() tests := []struct { name string blockID string wantBlockID string wantSlideType string wantErr string }{ { name: "compound block id", blockID: "shape!bPq", wantBlockID: "bPq", wantSlideType: "shape", }, { name: "missing block id", wantErr: "slide comments require --block-id", }, { name: "invalid compound", blockID: "shape!", wantErr: "!", }, { name: "missing separator", blockID: "bPq", wantErr: "!", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() gotBlockID, gotSlideType, err := parseSlidesBlockRef(tt.blockID) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if gotBlockID != tt.wantBlockID || gotSlideType != tt.wantSlideType { t.Fatalf("expected (%q, %q), got (%q, %q)", tt.wantBlockID, tt.wantSlideType, gotBlockID, gotSlideType) } }) } } // ── Sheet comment validate tests ──────────────────────────────────────────── func TestSheetCommentValidateMissingBlockID(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/sheets/shtToken", "--content", `[{"type":"text","text":"test"}]`, "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "--block-id is required") { t.Fatalf("expected block-id required error, got: %v", err) } } func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/sheets/shtToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "no-exclamation", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "!") { t.Fatalf("expected format error, got: %v", err) } } func TestSheetCommentValidateRejectsFullComment(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/sheets/shtToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "s1!A1", "--full-comment", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") { t.Fatalf("expected incompatible flags error, got: %v", err) } } func TestSheetCommentValidateRejectsSelectionWithEllipsis(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/sheets/shtToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "s1!A1", "--selection-with-ellipsis", "something", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") { t.Fatalf("expected incompatible flags error, got: %v", err) } } // ── Slides comment validate tests ─────────────────────────────────────────── func TestSlidesCommentValidateMissingBlockID(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/slides/presToken", "--content", `[{"type":"text","text":"test"}]`, "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "slide comments require --block-id") { t.Fatalf("expected block-id required error, got: %v", err) } } func TestSlidesCommentValidateRejectsFullComment(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/slides/presToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "shape!shape_1", "--full-comment", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "not applicable for slide comments") { t.Fatalf("expected incompatible flags error, got: %v", err) } } func TestSlidesCommentValidateRejectsSelectionWithEllipsis(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/slides/presToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "shape!shape_1", "--selection-with-ellipsis", "something", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "not applicable for slide comments") { t.Fatalf("expected incompatible flags error, got: %v", err) } } func TestSlidesCommentValidateRejectsLegacyBlockID(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/slides/presToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "shape_1", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "!") { t.Fatalf("expected compound block-id format error, got: %v", err) } } func TestSlidesCommentValidateCompoundBlockID(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/slides/presToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "shape!shape_1", "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestFileCommentValidateRejectsBlockID(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/file/fileToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "blk_123", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") { t.Fatalf("expected file local-comment rejection, got: %v", err) } } func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/file/fileToken", "--content", `[{"type":"text","text":"test"}]`, "--selection-with-ellipsis", "something", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") { t.Fatalf("expected file local-comment rejection, got: %v", err) } } func TestBaseCommentValidateMissingBlockID(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/base/baseToken", "--content", `[{"type":"text","text":"test"}]`, "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "--block-id is required") { t.Fatalf("expected block-id required error, got: %v", err) } } func TestBaseCommentValidateMalformedBlockID(t *testing.T) { cases := []string{ "tbl9mp6fj9kDKHQV", "tbl9mp6fj9kDKHQV!recBIBgGmb", "tbl9mp6fj9kDKHQV!!vewc46MG1R", } for _, blockID := range cases { t.Run(blockID, func(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/base/baseToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", blockID, "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "!!") { t.Fatalf("expected block-id format error, got: %v", err) } }) } } func TestBaseCommentValidateRejectsIncompatibleFlags(t *testing.T) { cases := []struct { name string args []string wantErr string }{ { name: "full comment", args: []string{"--full-comment"}, wantErr: "--full-comment is not applicable for base(bitable) comments", }, { name: "selection", args: []string{"--selection-with-ellipsis", "some text"}, wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) args := []string{ "+add-comment", "--doc", "https://example.larksuite.com/base/baseToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", "--as", "user", } args = append(args, tc.args...) err := mountAndRunDrive(t, DriveAddComment, args, f, stdout) if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("expected %q error, got: %v", tc.wantErr, err) } }) } } // ── Slides comment execute tests ──────────────────────────────────────────── func TestSlidesCommentExecuteSuccess(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/presToken/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "slideComment123", "created_at": 1700000000}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/slides/presToken", "--content", `[{"type":"text","text":"请看这个元素"}]`, "--block-id", "shape!shape_1", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "slideComment123") { t.Fatalf("stdout missing comment_id: %s", stdout.String()) } out := decodeJSONMap(t, stdout.String()) data := mustMapValue(t, out["data"], "data") if got := mustStringField(t, data, "file_type", "data.file_type"); got != "slides" { t.Fatalf("stdout file_type = %q, want slides\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, data, "slide_block_type", "data.slide_block_type"); got != "shape" { t.Fatalf("stdout slide_block_type = %q, want shape\nstdout:\n%s", got, stdout.String()) } } func TestSlidesCommentExecuteSuccessWithCompoundBlockID(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/presToken/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "slideCommentCompound"}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/slides/presToken", "--content", `[{"type":"text","text":"请看这个元素"}]`, "--block-id", "shape!shape_9", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "slideCommentCompound") { t.Fatalf("stdout missing comment_id: %s", stdout.String()) } out := decodeJSONMap(t, stdout.String()) data := mustMapValue(t, out["data"], "data") if got := mustStringField(t, data, "anchor_block_id", "data.anchor_block_id"); got != "shape_9" { t.Fatalf("stdout anchor_block_id = %q, want shape_9\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, data, "slide_block_type", "data.slide_block_type"); got != "shape" { t.Fatalf("stdout slide_block_type = %q, want shape\nstdout:\n%s", got, stdout.String()) } } func TestSlidesCommentViaWikiResolve(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{ "obj_type": "slides", "obj_token": "presResolved", }, }, }, }) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/presResolved/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "wikiSlideComment"}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiSlidesToken", "--content", `[{"type":"text","text":"wiki slide comment"}]`, "--block-id", "shape!shape_7", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "wikiSlideComment") { t.Fatalf("stdout missing comment_id: %s", stdout.String()) } } // ── Sheet comment execute tests ───────────────────────────────────────────── func TestSheetCommentExecuteSuccess(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "comment123", "created_at": 1700000000}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/sheets/shtToken", "--content", `[{"type":"text","text":"请检查"}]`, "--block-id", "s1!D6", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "comment123") { t.Fatalf("stdout missing comment_id: %s", stdout.String()) } } func TestSheetCommentExecuteWithURL(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/shtFromURL/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "c456"}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/sheets/shtFromURL?sheet=abc", "--content", `[{"type":"text","text":"ok"}]`, "--block-id", "abc!A1", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestSheetCommentViaWikiResolve(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{ "obj_type": "sheet", "obj_token": "shtResolved", }, }, }, }) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/shtResolved/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "wikiSheetComment"}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken123", "--content", `[{"type":"text","text":"wiki sheet comment"}]`, "--block-id", "s1!B3", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "wikiSheetComment") { t.Fatalf("stdout missing comment_id: %s", stdout.String()) } } func TestSheetCommentViaWikiMissingBlockID(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{ "obj_type": "sheet", "obj_token": "shtResolved", }, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken123", "--content", `[{"type":"text","text":"test"}]`, "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "--block-id is required") { t.Fatalf("expected block-id required error, got: %v", err) } } func TestBaseCommentExecuteSuccess(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) createStub := &httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/baseToken/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "comment_id": "baseComment123", "reply_id": "baseReply123", "created_at": 1700000000, }, }, } reg.Register(createStub) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/base/baseToken", "--content", `[{"type":"text","text":"请看这条记录"}]`, "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } var requestBody map[string]interface{} if err := json.Unmarshal(createStub.CapturedBody, &requestBody); err != nil { t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(createStub.CapturedBody)) } if got := mustStringField(t, requestBody, "file_type", "request.file_type"); got != "bitable" { t.Fatalf("request file_type = %q, want bitable", got) } anchor := mustMapValue(t, requestBody["anchor"], "request.anchor") if got := mustStringField(t, anchor, "block_id", "request.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" { t.Fatalf("request block_id = %q, want tbl9mp6fj9kDKHQV", got) } if got := mustStringField(t, anchor, "base_record_id", "request.anchor.base_record_id"); got != "recBIBgGmb" { t.Fatalf("request base_record_id = %q, want recBIBgGmb", got) } if got := mustStringField(t, anchor, "base_view_id", "request.anchor.base_view_id"); got != "vewc46MG1R" { t.Fatalf("request base_view_id = %q, want vewc46MG1R", got) } out := decodeJSONMap(t, stdout.String()) data := mustMapValue(t, out["data"], "data") if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" { t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, data, "comment_mode", "data.comment_mode"); got != "base_record" { t.Fatalf("stdout comment_mode = %q, want base_record\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "baseReply123" { t.Fatalf("stdout reply_id = %q, want baseReply123\nstdout:\n%s", got, stdout.String()) } } func TestBaseCommentExecuteBareToken(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/baseBareToken/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "baseBareComment"}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "baseBareToken", "--type", "bitable", "--content", `[{"type":"text","text":"ok"}]`, "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "baseBareComment") { t.Fatalf("stdout missing comment_id: %s", stdout.String()) } } func TestFileCommentExecuteSuccess(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "metas": []interface{}{ map[string]interface{}{"title": "README.txt"}, }, }, }, }) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/fileToken/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "fileComment123", "created_at": 1700000000}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/file/fileToken", "--content", `[{"type":"text","text":"请补充 README 示例"}]`, "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "fileComment123") { t.Fatalf("stdout missing comment_id: %s", stdout.String()) } out := decodeJSONMap(t, stdout.String()) data := mustMapValue(t, out["data"], "data") if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" { t.Fatalf("stdout file_type = %q, want file\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, data, "file_name", "data.file_name"); got != "README.txt" { t.Fatalf("stdout file_name = %q, want README.txt\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, data, "file_extension", "data.file_extension"); got != ".txt" { t.Fatalf("stdout file_extension = %q, want .txt\nstdout:\n%s", got, stdout.String()) } } func TestFileCommentExecuteRejectsUnsupportedFileType(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "metas": []interface{}{ map[string]interface{}{"title": "notes.pdf"}, }, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/file/fileToken", "--content", `[{"type":"text","text":"test"}]`, "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "does not support comments for this Drive file type yet") { t.Fatalf("expected unsupported file comment type error, got: %v", err) } if !strings.Contains(err.Error(), "notes.pdf") { t.Fatalf("expected error to mention unsupported title, got: %v", err) } } func TestFileCommentExecuteRejectsUnexpectedMetadataFormat(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "metas": []interface{}{"unexpected"}, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/file/fileToken", "--content", `[{"type":"text","text":"test"}]`, "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "unexpected metadata format") { t.Fatalf("expected unexpected metadata format error, got: %v", err) } } func TestFileCommentSupportedExtensions(t *testing.T) { t.Parallel() supported := []string{ "README.md", "notes.TXT", "data.json", "table.csv", "main.go", "app.js", "script.py", "slides.pptx", "image.png", "photo.jpg", "photo.jpeg", ".md", "archive.zip", "audio.mp3", "video.mp4", } for _, title := range supported { extension := fileCommentExtension(title) if !isSupportedFileCommentExtension(extension) { t.Fatalf("%s extension %q should be supported", title, extension) } } unsupported := []string{ "report.pdf", "word.docx", "sheet.xlsx", "unknown.bin", "no-extension", ".gitignore", } for _, title := range unsupported { extension := fileCommentExtension(title) if isSupportedFileCommentExtension(extension) { t.Fatalf("%s extension %q should not be supported", title, extension) } } if extension := fileCommentExtension(".gitignore"); extension != "" { t.Fatalf("dotfile extension = %q, want empty", extension) } } // ── DryRun coverage ───────────────────────────────────────────────────────── func TestDryRunSheetDirectURL(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/sheets/shtToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "s1!A1", "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "sheet comment") { t.Fatalf("dry-run output missing sheet comment: %s", stdout.String()) } } func TestDryRunWikiResolvesToSheet(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": "sheet", "obj_token": "shtResolved"}, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "s1!D6", "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "sheet comment") { t.Fatalf("dry-run output missing sheet comment: %s", stdout.String()) } } func TestDryRunWikiResolvesToDocxFull(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"}, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "full comment") { t.Fatalf("dry-run output missing full comment: %s", stdout.String()) } } func TestDryRunSlidesDirectURL(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/slides/presToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "shape!shape_1", "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "slide block comment") { t.Fatalf("dry-run output missing slide block comment: %s", stdout.String()) } out := decodeJSONMap(t, stdout.String()) api := mustSliceValue(t, out["api"], "api") call := mustMapValue(t, api[0], "api[0]") body := mustMapValue(t, call["body"], "api[0].body") anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor") if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "slides" { t.Fatalf("dry-run body.file_type = %q, want slides\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, anchor, "slide_block_type", "api[0].body.anchor.slide_block_type"); got != "shape" { t.Fatalf("dry-run body.anchor.slide_block_type = %q, want shape\nstdout:\n%s", got, stdout.String()) } } func TestDryRunBaseDirectURL(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/base/baseToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "record-local comment") { t.Fatalf("dry-run output missing record-local comment: %s", stdout.String()) } out := decodeJSONMap(t, stdout.String()) api := mustSliceValue(t, out["api"], "api") call := mustMapValue(t, api[0], "api[0]") body := mustMapValue(t, call["body"], "api[0].body") anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor") if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "bitable" { t.Fatalf("dry-run body.file_type = %q, want bitable\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, anchor, "block_id", "api[0].body.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" { t.Fatalf("dry-run body.anchor.block_id = %q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, anchor, "base_record_id", "api[0].body.anchor.base_record_id"); got != "recBIBgGmb" { t.Fatalf("dry-run body.anchor.base_record_id = %q, want recBIBgGmb\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, anchor, "base_view_id", "api[0].body.anchor.base_view_id"); got != "vewc46MG1R" { t.Fatalf("dry-run body.anchor.base_view_id = %q, want vewc46MG1R\nstdout:\n%s", got, stdout.String()) } } func TestDryRunWikiResolvesToSlides(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": "slides", "obj_token": "presResolved"}, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "shape!shape_2", "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "slide block comment") { t.Fatalf("dry-run output missing slide block comment: %s", stdout.String()) } out := decodeJSONMap(t, stdout.String()) api := mustSliceValue(t, out["api"], "api") call := mustMapValue(t, api[0], "api[0]") body := mustMapValue(t, call["body"], "api[0].body") anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor") if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "slides" { t.Fatalf("dry-run body.file_type = %q, want slides\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, anchor, "slide_block_type", "api[0].body.anchor.slide_block_type"); got != "shape" { t.Fatalf("dry-run body.anchor.slide_block_type = %q, want shape\nstdout:\n%s", got, stdout.String()) } } func TestDryRunWikiSlidesInvalidBlockIDSurfaces(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": "slides", "obj_token": "presResolved"}, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "shape_2", "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "slide --block-id must be") || !strings.Contains(stdout.String(), "shape_2") { t.Fatalf("dry-run output missing block-id format error: %s", stdout.String()) } out := decodeJSONMap(t, stdout.String()) api := mustSliceValue(t, out["api"], "api") if len(api) != 0 { t.Fatalf("dry-run should not preview API calls with malformed block-id: %s", stdout.String()) } if _, ok := out["error"].(string); !ok { t.Fatalf("dry-run output missing structured error: %s", stdout.String()) } } func TestDryRunWikiSlidesResolutionErrorSurfaces(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": "slides", "obj_token": "presResolved"}, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "slide comments require --block-id") { t.Fatalf("dry-run output missing resolution error: %s", stdout.String()) } if strings.Contains(stdout.String(), "/open-apis/drive/v1/files/wikiToken/new_comments") { t.Fatalf("dry-run should not fall back to unresolved wiki token: %s", stdout.String()) } } func TestDryRunDocxLocalWithBlockID(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/docx/docxToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "blk_123", "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "local comment") { t.Fatalf("dry-run output missing local comment: %s", stdout.String()) } } func TestDryRunDocxFullComment(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/docx/docxToken", "--content", `[{"type":"text","text":"test"}]`, "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "full comment") { t.Fatalf("dry-run output missing full comment: %s", stdout.String()) } } func TestDryRunFileDirectURL(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/file/fileToken", "--content", `[{"type":"text","text":"test"}]`, "--dry-run", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "verify supported file metadata") { t.Fatalf("dry-run output missing supported file metadata verification step: %s", stdout.String()) } out := decodeJSONMap(t, stdout.String()) api := mustSliceValue(t, out["api"], "api") if len(api) != 2 { t.Fatalf("expected 2 dry-run api calls, got %d\nstdout:\n%s", len(api), stdout.String()) } verifyCall := mustMapValue(t, api[0], "api[0]") createCall := mustMapValue(t, api[1], "api[1]") verifyBody := mustMapValue(t, verifyCall["body"], "api[0].body") createBody := mustMapValue(t, createCall["body"], "api[1].body") requestDocs := mustSliceValue(t, verifyBody["request_docs"], "api[0].body.request_docs") requestDoc := mustMapValue(t, requestDocs[0], "api[0].body.request_docs[0]") if got := mustStringField(t, requestDoc, "doc_type", "api[0].body.request_docs[0].doc_type"); got != "file" { t.Fatalf("metadata query doc_type = %q, want file\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, createBody, "file_type", "api[1].body.file_type"); got != "file" { t.Fatalf("comment create file_type = %q, want file\nstdout:\n%s", got, stdout.String()) } anchor := mustMapValue(t, createBody["anchor"], "api[1].body.anchor") if got := mustStringField(t, anchor, "block_id", "api[1].body.anchor.block_id"); got != fileFullCommentAnchorBlockID { t.Fatalf("comment create anchor.block_id = %q, want %q\nstdout:\n%s", got, fileFullCommentAnchorBlockID, stdout.String()) } } // ── resolveCommentTarget coverage ─────────────────────────────────────────── func TestResolveWikiToDocxFullComment(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"}, }, }, }) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/docxResolved/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "wikiDocxComment"}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "wikiDocxComment") { t.Fatalf("stdout missing comment_id: %s", stdout.String()) } } func TestResolveWikiToBaseComment(t *testing.T) { for _, objType := range []string{"bitable", "base"} { t.Run(objType, func(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": objType, "obj_token": "bitToken"}, }, }, }) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"}, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "wikiBaseComment") { t.Fatalf("stdout missing comment_id: %s", stdout.String()) } out := decodeJSONMap(t, stdout.String()) data := mustMapValue(t, out["data"], "data") if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" { t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String()) } if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" { t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String()) } }) } } func TestResolveWikiToBaseRejectsIncompatibleFlags(t *testing.T) { cases := []struct { name string args []string wantErr string }{ { name: "full comment", args: []string{"--full-comment"}, wantErr: "--full-comment is not applicable for base(bitable) comments", }, { name: "selection", args: []string{"--selection-with-ellipsis", "some text"}, wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"}, }, }, }) args := []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, "--as", "user", } args = append(args, tc.args...) err := mountAndRunDrive(t, DriveAddComment, args, f, stdout) if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("expected %q error, got: %v", tc.wantErr, err) } }) } } func TestResolveWikiToSlidesFullCommentRejected(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": "slides", "obj_token": "presToken"}, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiSlidesToken", "--content", `[{"type":"text","text":"test"}]`, "--full-comment", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "slide comments require --block-id") { t.Fatalf("expected slides full-comment rejection, got: %v", err) } } func TestResolveWikiToSlidesSelectionRejected(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{"obj_type": "slides", "obj_token": "presToken"}, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiSlidesToken", "--content", `[{"type":"text","text":"test"}]`, "--selection-with-ellipsis", "something", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "--selection-with-ellipsis is not applicable for slide comments") { t.Fatalf("expected slides selection rejection, got: %v", err) } } func TestResolveWikiIncompleteNodeData(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", Body: map[string]interface{}{ "code": 0, "msg": "success", "data": map[string]interface{}{ "node": map[string]interface{}{}, }, }, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "incomplete node data") { t.Fatalf("expected incomplete node error, got: %v", err) } } func TestDocOldFormatLocalCommentRejected(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/doc/oldDocToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "blk_123", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, slides, and base(bitable)") { t.Fatalf("expected local comment rejection for old doc, got: %v", err) } } // ── Additional unit function tests ────────────────────────────────────────── func TestAnchorBlockIDForDryRun(t *testing.T) { t.Parallel() if got := anchorBlockIDForDryRun("blk_123"); got != "blk_123" { t.Fatalf("expected blk_123, got %s", got) } if got := anchorBlockIDForDryRun(""); got != "" { t.Fatalf("expected placeholder, got %s", got) } if got := anchorBlockIDForDryRun(" "); got != "" { t.Fatalf("expected placeholder for whitespace, got %s", got) } } func TestParseSheetCellRefRowZero(t *testing.T) { t.Parallel() _, err := parseSheetCellRef("s1!A0") if err == nil || !strings.Contains(err.Error(), "must be >= 1") { t.Fatalf("expected row validation error, got: %v", err) } } func TestParseCommentDocRefPathLikeToken(t *testing.T) { t.Parallel() _, err := parseCommentDocRef("token/with/slash", "") if err == nil || !strings.Contains(err.Error(), "unsupported --doc input") { t.Fatalf("expected unsupported doc error, got: %v", err) } } func TestExtractURLTokenEmptyAfterMarker(t *testing.T) { t.Parallel() _, ok := extractURLToken("https://example.com/sheets/", "/sheets/") if ok { t.Fatal("expected false for empty token after marker") } } func TestSheetCommentExecuteAPIError(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments", Status: 400, Body: map[string]interface{}{"code": 1061002, "msg": "params error"}, }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/sheets/shtToken", "--content", `[{"type":"text","text":"test"}]`, "--block-id", "s1!A1", "--as", "user", }, f, nil) if err == nil { t.Fatal("expected error") } }