diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index cf465741..83ff658a 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -4150,6 +4150,10 @@ role-get 自行 merge)。 if err := validateRequiredFlags(cmd, "file-name"); err != nil { return err } + fileSize, _ := cmd.Flags().GetInt64("file-size") + if fileSize <= 0 { + return fmt.Errorf("flag --file-size is required and must be a positive integer") + } baseID, err := mustFlagOrFallback(cmd, "base-id", "base") if err != nil { return err @@ -4157,9 +4161,7 @@ role-get 自行 merge)。 toolArgs := map[string]any{ "baseId": baseID, "fileName": mustGetFlag(cmd, "file-name"), - } - if v, _ := cmd.Flags().GetInt64("file-size"); v > 0 { - toolArgs["fileSize"] = v + "fileSize": fileSize, } return callAitableTool("prepare_import_upload", toolArgs) }, diff --git a/internal/helpers/aitable_import_upload_test.go b/internal/helpers/aitable_import_upload_test.go new file mode 100644 index 00000000..13f400e8 --- /dev/null +++ b/internal/helpers/aitable_import_upload_test.go @@ -0,0 +1,82 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package helpers + +import ( + "context" + "io" + "reflect" + "strings" + "testing" +) + +func runAitableImportUploadCommand(t *testing.T, args ...string) (*aitableTestCaller, error) { + t.Helper() + caller := &aitableTestCaller{} + installAitableDeps(t, caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + + cmd := newAitableCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(append([]string{"import", "upload"}, args...)) + return caller, cmd.ExecuteContext(context.Background()) +} + +func TestAitableImportUploadRequiresPositiveFileSize(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "missing", args: nil}, + {name: "zero", args: []string{"--file-size", "0"}}, + {name: "negative", args: []string{"--file-size", "-1"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := []string{"--base-id", "base-smoke", "--file-name", "data.xlsx"} + caller, err := runAitableImportUploadCommand(t, append(args, test.args...)...) + if err == nil { + t.Fatal("expected --file-size validation error") + } + if !strings.Contains(err.Error(), "--file-size") { + t.Fatalf("error = %q, want --file-size validation", err) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid file size dispatched %d MCP call(s): %#v", len(caller.calls), caller.calls) + } + }) + } +} + +func TestAitableImportUploadPassesFileSizeToMCP(t *testing.T) { + caller, err := runAitableImportUploadCommand(t, + "--base-id", "base-smoke", + "--file-name", "data.xlsx", + "--file-size", "204800", + ) + if err != nil { + t.Fatalf("aitable import upload returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + call := caller.calls[0] + if call.server != "aitable" || call.tool != "prepare_import_upload" { + t.Fatalf("tool call = %s/%s, want aitable/prepare_import_upload", call.server, call.tool) + } + want := map[string]any{ + "baseId": "base-smoke", + "fileName": "data.xlsx", + "fileSize": int64(204800), + } + if !reflect.DeepEqual(call.args, want) { + t.Fatalf("tool args = %#v, want %#v", call.args, want) + } +} diff --git a/internal/shortcut/aitable/aitable.go b/internal/shortcut/aitable/aitable.go index 1ce20aab..14a7418b 100644 --- a/internal/shortcut/aitable/aitable.go +++ b/internal/shortcut/aitable/aitable.go @@ -2107,16 +2107,27 @@ var ImportUpload = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, {Name: "file-name", Type: shortcut.FlagString, Desc: "文件名(含扩展名)", Required: true}, - {Name: "file-size", Type: shortcut.FlagInt, Desc: "文件大小(字节,可选)"}, + {Name: "file-size", Type: shortcut.FlagInt, Desc: "文件大小(字节,必须大于 0)", Required: true}, + }, + Constraints: []shortcut.Constraint{ + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"file-size"}, + Description: "--file-size 必须是大于 0 的整数(实际文件大小,单位字节)", + }, }, Tips: []string{`dws aitable +import-upload --base-id B --file-name data.xlsx --file-size 204800`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("file-size") <= 0 { + return fmt.Errorf("flag --file-size is required and must be a positive integer") + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{ "baseId": rt.Str("base-id"), "fileName": rt.Str("file-name"), - } - if rt.Changed("file-size") { - params["fileSize"] = rt.Int("file-size") + "fileSize": rt.Int("file-size"), } return rt.CallMCP("prepare_import_upload", params) }, diff --git a/internal/shortcut/aitable/compatibility_coverage_test.go b/internal/shortcut/aitable/compatibility_coverage_test.go new file mode 100644 index 00000000..458d95f1 --- /dev/null +++ b/internal/shortcut/aitable/compatibility_coverage_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aitable + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type platformCoverageCaller struct { + called bool + product string + tool string + args map[string]any +} + +func (f *platformCoverageCaller) reset() { + f.called, f.product, f.tool, f.args = false, "", "", nil +} + +func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + f.called, f.product, f.tool, f.args = true, product, tool, args + return &edition.ToolResult{ + Content: []edition.ContentBlock{{Type: "text", Text: `{"result":[]}`}}, + }, nil +} + +func (f *platformCoverageCaller) Format() string { return "json" } +func (f *platformCoverageCaller) DryRun() bool { return false } +func (f *platformCoverageCaller) Fields() string { return "" } +func (f *platformCoverageCaller) JQ() string { return "" } + +func newPlatformCoverageRoot() *cobra.Command { + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + return root +} + +func TestCrossPlatformCoverageImportUploadRequiresPositiveFileSize(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + + tests := []struct { + name string + fileSize string + wantErr bool + }{ + {name: "missing", wantErr: true}, + {name: "zero", fileSize: "0", wantErr: true}, + {name: "negative", fileSize: "-1", wantErr: true}, + {name: "positive", fileSize: "204800"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fake.reset() + root := newPlatformCoverageRoot() + args := []string{ + "aitable", "+import-upload", + "--base-id", "base-smoke", + "--file-name", "data.xlsx", + "--yes", + } + if test.fileSize != "" { + args = append(args, "--file-size", test.fileSize) + } + root.SetArgs(args) + err := root.Execute() + + if test.wantErr { + if err == nil { + t.Fatal("expected --file-size validation error") + } + if !strings.Contains(err.Error(), "--file-size") { + t.Fatalf("error = %q, want --file-size validation", err) + } + if fake.called { + t.Fatalf("invalid file size called %s/%s with %#v", fake.product, fake.tool, fake.args) + } + return + } + + if err != nil { + t.Fatalf("positive file size returned error: %v", err) + } + if !fake.called || fake.product != "aitable" || fake.tool != "prepare_import_upload" { + t.Fatalf("tool call = called:%v %s/%s, want aitable/prepare_import_upload", fake.called, fake.product, fake.tool) + } + if got := fake.args["fileSize"]; got != 204800 { + t.Fatalf("fileSize = %#v, want 204800", got) + } + }) + } +}