Files
larksuite-cli/shortcuts/drive/drive_add_comment_test.go
wangweiming-01 e54220ade1 feat: support files in drive +add-comment (#975)
* feat: support markdown files in drive +add-comment

Change-Id: Id9a87706a1e43756d8142637be9ec1e0748d4ddf

* fix: use markdown file comment anchor placeholder

Change-Id: Ifffc4cdd963c13e53f4cad154aebe11ae309df9e

* fix: gate drive file comments by supported extensions

Change-Id: Ie6c7f38dbbea1f87a81600da71180627b53a2355
2026-05-21 21:40:27 +08:00

1784 lines
58 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
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 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: "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 &lt; b &gt; 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 '&lt;' (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 ExitError.Detail.Hint, not err.Error().
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint)
}
}
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 exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
}
hint := exitErr.Detail.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")
}
}
// ── 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: "<slide-block-type>!<xml-id>",
},
{
name: "missing separator",
blockID: "bPq",
wantErr: "<slide-block-type>!<xml-id>",
},
}
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(), "<sheetId>!<cell>") {
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(), "<slide-block-type>!<xml-id>") {
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)
}
}
// ── 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 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 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 TestResolveWikiToUnsupportedType(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"},
},
},
})
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(), "only support doc/docx/file/sheet/slides") {
t.Fatalf("expected unsupported type error, got: %v", 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, and slides") {
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 != "<anchor_block_id>" {
t.Fatalf("expected placeholder, got %s", got)
}
if got := anchorBlockIDForDryRun(" "); got != "<anchor_block_id>" {
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")
}
}