feat(docs): add reference map flags (#1547)

This commit is contained in:
SunPeiYang996
2026-06-30 12:07:18 +08:00
committed by GitHub
parent 31744f8cf9
commit 22108c3300
10 changed files with 282 additions and 12 deletions

View File

@@ -14,6 +14,8 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
@@ -88,7 +90,8 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": effectiveFetchFormat(runtime),
"format": effectiveFetchFormat(runtime),
"extra_param": docsFetchExtraParam,
}
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v

View File

@@ -488,6 +488,44 @@ func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
}
}
func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
body := buildFetchBody(runtime)
extraParam, ok := body["extra_param"].(string)
if !ok || extraParam == "" {
t.Fatalf("extra_param = %#v, want JSON string", body["extra_param"])
}
var got map[string]bool
if err := json.Unmarshal([]byte(extraParam), &got); err != nil {
t.Fatalf("decode extra_param %q: %v", extraParam, err)
}
if got["enable_user_cite_reference_map"] != true {
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
}
if _, ok := got["return_html5_block_data"]; ok {
t.Fatalf("extra_param should not request html5 block data: %#v", got)
}
if _, ok := got["reference_map_mode"]; ok {
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
}
if len(got) != 1 {
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
}
}
func TestDocsFetchV2ReferenceMapFlagIsNotAvailable(t *testing.T) {
t.Parallel()
for _, flag := range v2FetchFlags() {
if flag.Name == "reference-map" {
t.Fatal("fetch should not expose reference-map flag")
}
}
}
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
t.Parallel()
@@ -904,6 +942,7 @@ func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", 0, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("reference-map", "", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")

View File

@@ -4,9 +4,11 @@ package doc
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -61,6 +63,116 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
}
}
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
t.Parallel()
var flag common.Flag
for _, candidate := range v2UpdateFlags() {
if candidate.Name == "reference-map" {
flag = candidate
break
}
}
if flag.Name == "" {
t.Fatal("reference-map flag not found")
}
if flag.Hidden {
t.Fatal("reference-map flag should be public")
}
if flag.Type != "" {
t.Fatalf("reference-map flag Type = %q, want default string", flag.Type)
}
if !hasUpdateTestInput(flag, common.File) || !hasUpdateTestInput(flag, common.Stdin) {
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
}
if flag.Desc != docsUpdateReferenceMapFlagDesc {
t.Fatalf("reference-map help = %q, want %q", flag.Desc, docsUpdateReferenceMapFlagDesc)
}
}
func TestBuildUpdateBodyIncludesReferenceMap(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"command": "append",
"content": `<p><widget data-ref="r1"></widget></p>`,
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
})
body := buildUpdateBody(runtime)
refMap, ok := body["reference_map"].(map[string]interface{})
if !ok {
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
}
widget, _ := refMap["widget"].(map[string]interface{})
r1, _ := widget["r1"].(map[string]interface{})
if got := r1["label"]; got != "widget-ref-value" {
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
}
if got, want := body["command"], "block_insert_after"; got != want {
t.Fatalf("command = %#v, want %q", got, want)
}
if got, want := body["block_id"], "-1"; got != want {
t.Fatalf("block_id = %#v, want %q", got, want)
}
}
func TestValidateUpdateV2RejectsInvalidReferenceMap(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
wantCause bool
}{
{
name: "invalid json",
setFlags: map[string]string{
"reference-map": "{",
},
wantCause: true,
},
{
name: "empty",
setFlags: map[string]string{
"reference-map": "",
},
},
{
name: "without content",
setFlags: map[string]string{
"content": "",
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
},
},
{
name: "unsupported command",
setFlags: map[string]string{
"command": "block_move_after",
"block-id": "blk_anchor",
"src-block-ids": "blk_src",
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
err := validateUpdateV2(context.Background(), runtime)
if err == nil {
t.Fatal("validateUpdateV2() succeeded, want error")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--reference-map")
if tt.wantCause && errors.Unwrap(err) == nil {
t.Fatal("validateUpdateV2() error lost underlying JSON cause")
}
})
}
}
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
tests := []struct {
name string
@@ -103,6 +215,15 @@ func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
}
}
func hasUpdateTestInput(flag common.Flag, input string) bool {
for _, candidate := range flag.Input {
if candidate == input {
return true
}
}
return false
}
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
t.Helper()
@@ -113,6 +234,7 @@ func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("reference-map", "", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")

View File

@@ -5,7 +5,9 @@ package doc
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
@@ -22,12 +24,15 @@ var validCommandsV2 = map[string]bool{
"append": true,
}
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
@@ -54,6 +59,9 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command")
}
content := runtime.Str("content")
if err := validateUpdateReferenceMap(runtime, cmd, content); err != nil {
return err
}
pattern := runtime.Str("pattern")
blockID := runtime.Str("block-id")
srcBlockIDs := runtime.Str("src-block-ids")
@@ -113,7 +121,7 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
body := buildUpdateBody(runtime)
body, _ := buildUpdateBodyWithReferenceMap(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
@@ -126,7 +134,10 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocumentRef(runtime.Str("doc"))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body := buildUpdateBody(runtime)
body, err := buildUpdateBodyWithReferenceMap(runtime)
if err != nil {
return err
}
data, err := doDocAPI(runtime, "PUT", apiPath, body)
if err != nil {
@@ -138,6 +149,24 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
}
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
body, _ := buildUpdateBodyWithReferenceMap(runtime)
return body
}
func buildUpdateBodyWithReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildUpdateBodyBase(runtime)
if !runtime.Changed("reference-map") {
return body, nil
}
refMap, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
if err != nil {
return body, err
}
body["reference_map"] = refMap
return body, nil
}
func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} {
cmd := runtime.Str("command")
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
@@ -169,3 +198,40 @@ func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
injectDocsScene(runtime, body)
return body
}
func validateUpdateReferenceMap(runtime *common.RuntimeContext, command string, content string) error {
if !runtime.Changed("reference-map") {
return nil
}
if !updateCommandAcceptsReferenceMap(command) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map is only supported with update commands that send --content").WithParam("--reference-map")
}
if content == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content that uses matching sidecar refs").WithParam("--reference-map")
}
_, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
return err
}
func updateCommandAcceptsReferenceMap(command string) bool {
switch command {
case "str_replace", "block_insert_after", "block_replace", "overwrite", "append":
return true
default:
return false
}
}
func parseUpdateReferenceMap(raw string) (map[string]interface{}, error) {
if strings.TrimSpace(raw) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a non-empty JSON object").WithParam("--reference-map")
}
var refMap map[string]interface{}
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a valid JSON object: %v", err).WithParam("--reference-map").WithCause(err)
}
if refMap == nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a JSON object, got null").WithParam("--reference-map")
}
return refMap, nil
}

View File

@@ -162,6 +162,9 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
if _, ok := reqBody["extra_param"]; ok {
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
if err != nil {
@@ -213,6 +216,9 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
if _, ok := reqBody["extra_param"]; ok {
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
if err != nil {
@@ -283,6 +289,9 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
if !strings.Contains(out, `"output_dir": "./exports"`) {
t.Fatalf("stdout missing output_dir metadata: %s", out)
}
if tt.name == "markdown" && strings.Contains(out, `"extra_param"`) {
t.Fatalf("markdown dry-run must not enable docs fetch extra_param: %s", out)
}
})
}
}
@@ -333,6 +342,9 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
if _, ok := reqBody["extra_param"]; ok {
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
if err != nil {

View File

@@ -24,6 +24,7 @@
| `--command` | 是 | 操作指令(见下方指令速查表) |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
| `--reference-map` | 否 | 结构化 `reference_map` JSON object`--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。 |
| `--pattern` | 视指令 | 匹配文本str_replace |
| `--block-id` | 视指令 | 目标 block IDblock_* 操作),逗号分隔可批量删除,-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID逗号分隔用于 block_copy_insert_after / block_move_after |

View File

@@ -9,7 +9,7 @@
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
- TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token.
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance.
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
@@ -19,10 +19,10 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown` | |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
| ✕ | docs +search | shortcut | | none | search results are ambient and not yet stabilized for E2E |
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content` | |
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content`; optional `--reference-map` -> body `reference_map` | |
| ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions |

View File

@@ -24,9 +24,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
t.Cleanup(cancel)
tests := []struct {
name string
args []string
wantURL string
name string
args []string
wantURL string
wantExtraParam string
wantRefLabel string
}{
{
name: "create",
@@ -54,7 +56,8 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--doc", "doxcnDryRunE2E",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
wantExtraParam: `{"enable_user_cite_reference_map":true}`,
},
{
name: "update",
@@ -67,6 +70,19 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
},
{
name: "update reference-map",
args: []string{
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "append",
"--content", `<p><widget data-ref="r1"></widget></p>`,
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
wantRefLabel: "widget-ref-value",
},
{
name: "block_delete batch",
args: []string{
@@ -104,6 +120,14 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
if strings.Contains(combined, "--api-version") {
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
if tt.wantExtraParam != "" {
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
}
if tt.wantRefLabel != "" {
got := gjson.Get(result.Stdout, "api.0.body.reference_map.widget.r1.label").String()
require.Equal(t, tt.wantRefLabel, got, "stdout:\n%s", result.Stdout)
}
})
}
}

View File

@@ -14,7 +14,7 @@
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
@@ -29,7 +29,7 @@
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema` | dry-run only; no live export workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| ✕ | drive +import | shortcut | | none | no import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |

View File

@@ -92,6 +92,9 @@ func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.extra_param").Exists() {
t.Fatalf("markdown drive export must not enable docs fetch extra_param\nstdout:\n%s", out)
}
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
}