mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* feat: support docs create title option Change-Id: I6fd840fe813e5e664ea9ec680765fd41375cdebf * docs: refine docs title guidance Change-Id: I2f986a4606729bc791a1bff6c03aaa198b0798dc * docs: keep lark doc skill create example Change-Id: Ic7005e015c9e71a4582c1f4a8ac8222d552426d4 * test: allow docs create title flag in help Change-Id: I0226e20c6bf2187eb6c4f0d2d5e37ab9225d4171
428 lines
14 KiB
Go
428 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package doc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// testDocxToken is a bare docx token that parseDocumentRef accepts, letting the
|
|
// validation tests reach the flag checks that run after --doc is resolved.
|
|
const testDocxToken = "doxcnDocErrorsTestToken"
|
|
|
|
// docValidateRuntime builds a RuntimeContext carrying only the flags a Doc
|
|
// Validate function reads. String values are applied (and marked Changed) only
|
|
// when non-empty; int values are always applied so Changed() reports true,
|
|
// mirroring how cobra records an explicitly supplied numeric flag.
|
|
func docValidateRuntime(t *testing.T, str map[string]string, bools map[string]bool, ints map[string]int) *common.RuntimeContext {
|
|
t.Helper()
|
|
cmd := &cobra.Command{Use: "docs"}
|
|
fs := cmd.Flags()
|
|
for name, val := range str {
|
|
fs.String(name, "", "")
|
|
if val != "" {
|
|
if err := fs.Set(name, val); err != nil {
|
|
t.Fatalf("set --%s=%q: %v", name, val, err)
|
|
}
|
|
}
|
|
}
|
|
for name, val := range bools {
|
|
fs.Bool(name, false, "")
|
|
if val {
|
|
if err := fs.Set(name, "true"); err != nil {
|
|
t.Fatalf("set --%s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
for name, val := range ints {
|
|
fs.Int(name, 0, "")
|
|
if err := fs.Set(name, strconv.Itoa(val)); err != nil {
|
|
t.Fatalf("set --%s=%d: %v", name, val, err)
|
|
}
|
|
}
|
|
return common.TestNewRuntimeContext(cmd, nil)
|
|
}
|
|
|
|
// assertValidationContract pins the typed envelope every migrated Doc
|
|
// validation fault must emit: a *errs.ValidationError in CategoryValidation
|
|
// with the expected Subtype, the single offending flag in Param, and every
|
|
// involved flag in Params. Single-flag faults set Param and leave Params empty;
|
|
// multi-flag faults (mutual exclusion, "one of A or B") leave Param empty and
|
|
// enumerate each flag in Params so agents resolve them without parsing the text.
|
|
func assertValidationContract(t *testing.T, err error, wantSubtype errs.Subtype, wantParam string, wantParams ...string) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatal("expected validation error, got nil")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err)
|
|
}
|
|
if ve.Category != errs.CategoryValidation {
|
|
t.Errorf("category = %q, want %q", ve.Category, errs.CategoryValidation)
|
|
}
|
|
if ve.Subtype != wantSubtype {
|
|
t.Errorf("subtype = %q, want %q", ve.Subtype, wantSubtype)
|
|
}
|
|
if ve.Param != wantParam {
|
|
t.Errorf("param = %q, want %q", ve.Param, wantParam)
|
|
}
|
|
gotParams := make([]string, len(ve.Params))
|
|
for i, p := range ve.Params {
|
|
gotParams[i] = p.Name
|
|
}
|
|
if !slices.Equal(gotParams, wantParams) {
|
|
t.Errorf("params = %v, want %v", gotParams, wantParams)
|
|
}
|
|
}
|
|
|
|
func TestDocMediaInsertValidateContract(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
str map[string]string
|
|
bools map[string]bool
|
|
ints map[string]int
|
|
wantParam string
|
|
wantParams []string
|
|
}{
|
|
{
|
|
name: "neither file nor clipboard",
|
|
str: map[string]string{"doc": testDocxToken},
|
|
wantParam: "", // one-of-two flags: enumerated in Params
|
|
wantParams: []string{"--file", "--from-clipboard"},
|
|
},
|
|
{
|
|
name: "file and clipboard together",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png"},
|
|
bools: map[string]bool{"from-clipboard": true},
|
|
wantParam: "", // mutual exclusion: enumerated in Params
|
|
wantParams: []string{"--file", "--from-clipboard"},
|
|
},
|
|
{
|
|
name: "non-docx document",
|
|
str: map[string]string{"doc": "https://example.larksuite.com/doc/xxxxxx", "file": "dummy.png"},
|
|
wantParam: "--doc",
|
|
},
|
|
{
|
|
name: "blank selection",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "selection-with-ellipsis": " "},
|
|
wantParam: "--selection-with-ellipsis",
|
|
},
|
|
{
|
|
name: "before without selection",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png"},
|
|
bools: map[string]bool{"before": true},
|
|
wantParam: "--before",
|
|
},
|
|
{
|
|
name: "invalid file-view",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "bogus"},
|
|
wantParam: "--file-view",
|
|
},
|
|
{
|
|
name: "file-view without type file",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "card", "type": "image"},
|
|
wantParam: "--file-view",
|
|
},
|
|
{
|
|
name: "dimensions with non-image type",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "file"},
|
|
ints: map[string]int{"width": 100},
|
|
wantParam: "", // only --width was set here, so only it is enumerated
|
|
wantParams: []string{"--width"},
|
|
},
|
|
{
|
|
name: "non-positive width",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
|
|
ints: map[string]int{"width": 0},
|
|
wantParam: "--width",
|
|
},
|
|
{
|
|
name: "non-positive height",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
|
|
ints: map[string]int{"height": 0},
|
|
wantParam: "--height",
|
|
},
|
|
{
|
|
name: "width over maximum",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
|
|
ints: map[string]int{"width": 10001},
|
|
wantParam: "--width",
|
|
},
|
|
{
|
|
name: "height over maximum",
|
|
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
|
|
ints: map[string]int{"height": 10001},
|
|
wantParam: "--height",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rt := docValidateRuntime(t, tc.str, tc.bools, tc.ints)
|
|
err := DocMediaInsert.Validate(context.Background(), rt)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateCreateV2Contract(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
str map[string]string
|
|
wantParam string
|
|
wantParams []string
|
|
}{
|
|
{
|
|
name: "content required",
|
|
str: map[string]string{},
|
|
wantParam: "--content",
|
|
},
|
|
{
|
|
name: "parent token and position mutually exclusive",
|
|
str: map[string]string{"content": "<doc/>", "parent-token": "fldcnX", "parent-position": "my_library"},
|
|
wantParam: "", // mutual exclusion: enumerated in Params
|
|
wantParams: []string{"--parent-token", "--parent-position"},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rt := docValidateRuntime(t, tc.str, nil, nil)
|
|
err := validateCreateV2(context.Background(), rt)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateCreateV2AllowsTitleWithoutContent(t *testing.T) {
|
|
rt := docValidateRuntime(t, map[string]string{"title": "Only Title"}, nil, nil)
|
|
if err := validateCreateV2(context.Background(), rt); err != nil {
|
|
t.Fatalf("validateCreateV2() error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateFetchV2Contract(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
str map[string]string
|
|
ints map[string]int
|
|
wantParam string
|
|
wantParams []string
|
|
}{
|
|
{
|
|
name: "range mode without block ids",
|
|
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "range"},
|
|
wantParam: "", // either --start-block-id or --end-block-id: enumerated in Params
|
|
wantParams: []string{"--start-block-id", "--end-block-id"},
|
|
},
|
|
{
|
|
name: "keyword mode without keyword",
|
|
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "keyword"},
|
|
wantParam: "--keyword",
|
|
},
|
|
{
|
|
name: "section mode without start block id",
|
|
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "section"},
|
|
wantParam: "--start-block-id",
|
|
},
|
|
{
|
|
name: "negative context-before",
|
|
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "outline"},
|
|
ints: map[string]int{"context-before": -1},
|
|
wantParam: "--context-before",
|
|
},
|
|
{
|
|
name: "unknown scope",
|
|
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "bogus"},
|
|
wantParam: "--scope",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rt := docValidateRuntime(t, tc.str, nil, tc.ints)
|
|
err := validateFetchV2(context.Background(), rt)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildDocsSearchRequestPreservesParseCause pins the --filter parse faults:
|
|
// the typed envelope carries Param --filter and chains the original parse error
|
|
// so errors.Is/Unwrap traversal keeps the underlying JSON/time-parse detail.
|
|
func TestBuildDocsSearchRequestPreservesParseCause(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
filter string
|
|
}{
|
|
{"invalid filter json", "{not json"},
|
|
{"invalid open_time start", `{"open_time":{"start":"not-a-time"}}`},
|
|
{"invalid open_time end", `{"open_time":{"end":"not-a-time"}}`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := buildDocsSearchRequest("q", tc.filter, "", "15")
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if ve.Param != "--filter" {
|
|
t.Errorf("param = %q, want %q", ve.Param, "--filter")
|
|
}
|
|
if errors.Unwrap(ve) == nil {
|
|
t.Error("parse error not chained: errors.Unwrap == nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWrapDocNetworkErr pins wrapDocNetworkErr's contract: a typed error passes
|
|
// through untouched, while a raw error becomes a transport-level NetworkError
|
|
// that still chains the original cause for errors.Is/Unwrap.
|
|
func TestWrapDocNetworkErr(t *testing.T) {
|
|
t.Run("typed error passes through unchanged", func(t *testing.T) {
|
|
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input")
|
|
got := wrapDocNetworkErr(typed, "fetch failed")
|
|
if got != error(typed) {
|
|
t.Fatalf("typed error must pass through unchanged, got %T", got)
|
|
}
|
|
})
|
|
t.Run("raw error becomes transport network error", func(t *testing.T) {
|
|
raw := errors.New("dial tcp: i/o timeout")
|
|
got := wrapDocNetworkErr(raw, "fetch failed: %s", "docx")
|
|
var ne *errs.NetworkError
|
|
if !errors.As(got, &ne) {
|
|
t.Fatalf("raw error must become *errs.NetworkError, got %T", got)
|
|
}
|
|
if ne.Subtype != errs.SubtypeNetworkTransport {
|
|
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
|
}
|
|
if !errors.Is(got, raw) {
|
|
t.Error("cause not chained: errors.Is(got, raw) == false")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestWrapDocInputFileErr pins that a --file stat/read failure becomes a typed
|
|
// validation error tagged with the --file param and the cause preserved, so an
|
|
// agent knows which flag to fix even though the shared helper is flag-agnostic.
|
|
func TestWrapDocInputFileErr(t *testing.T) {
|
|
raw := errors.New("no such file or directory")
|
|
got := wrapDocInputFileErr(raw, "file not found")
|
|
var ve *errs.ValidationError
|
|
if !errors.As(got, &ve) {
|
|
t.Fatalf("error type = %T, want *errs.ValidationError (%v)", got, got)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if ve.Param != "--file" {
|
|
t.Errorf("param = %q, want %q", ve.Param, "--file")
|
|
}
|
|
if !errors.Is(got, raw) {
|
|
t.Error("cause not chained: errors.Is(got, raw) == false")
|
|
}
|
|
}
|
|
|
|
func TestValidateUpdateV2Contract(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
str map[string]string
|
|
wantParam string
|
|
}{
|
|
{
|
|
name: "command required",
|
|
str: map[string]string{"doc": testDocxToken},
|
|
wantParam: "--command",
|
|
},
|
|
{
|
|
name: "invalid command",
|
|
str: map[string]string{"doc": testDocxToken, "command": "bogus"},
|
|
wantParam: "--command",
|
|
},
|
|
{
|
|
name: "str_replace without pattern",
|
|
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
|
|
wantParam: "--pattern",
|
|
},
|
|
{
|
|
name: "block_delete without block id",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
|
|
wantParam: "--block-id",
|
|
},
|
|
{
|
|
name: "block_insert_after without block id",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},
|
|
wantParam: "--block-id",
|
|
},
|
|
{
|
|
name: "block_insert_after without content",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after", "block-id": "blkX"},
|
|
wantParam: "--content",
|
|
},
|
|
{
|
|
name: "block_copy_insert_after without block id",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after"},
|
|
wantParam: "--block-id",
|
|
},
|
|
{
|
|
name: "block_copy_insert_after without src block ids",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after", "block-id": "blkX"},
|
|
wantParam: "--src-block-ids",
|
|
},
|
|
{
|
|
name: "block_move_after without block id",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_move_after"},
|
|
wantParam: "--block-id",
|
|
},
|
|
{
|
|
name: "block_move_after without src block ids",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX"},
|
|
wantParam: "--src-block-ids",
|
|
},
|
|
{
|
|
name: "block_move_after rejects content",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX", "src-block-ids": "blkY", "content": "x"},
|
|
wantParam: "--content",
|
|
},
|
|
{
|
|
name: "block_replace without block id",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_replace"},
|
|
wantParam: "--block-id",
|
|
},
|
|
{
|
|
name: "block_replace without content",
|
|
str: map[string]string{"doc": testDocxToken, "command": "block_replace", "block-id": "blkX"},
|
|
wantParam: "--content",
|
|
},
|
|
{
|
|
name: "overwrite without content",
|
|
str: map[string]string{"doc": testDocxToken, "command": "overwrite"},
|
|
wantParam: "--content",
|
|
},
|
|
{
|
|
name: "append without content",
|
|
str: map[string]string{"doc": testDocxToken, "command": "append"},
|
|
wantParam: "--content",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rt := docValidateRuntime(t, tc.str, nil, nil)
|
|
err := validateUpdateV2(context.Background(), rt)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam)
|
|
})
|
|
}
|
|
}
|