// 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 helpers import ( "bytes" "context" "encoding/json" "fmt" "os" "path/filepath" "reflect" "strings" "testing" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) type sheetImportCall struct { server string tool string args map[string]any } type sheetImportCaller struct { calls []sheetImportCall responses map[string][]string dryRun bool } func (c *sheetImportCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { c.calls = append(c.calls, sheetImportCall{server: server, tool: tool, args: args}) responses := c.responses[tool] if len(responses) == 0 { return nil, fmt.Errorf("unexpected tool call %s/%s", server, tool) } c.responses[tool] = responses[1:] return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: responses[0]}}}, nil } func (*sheetImportCaller) Format() string { return "json" } func (c *sheetImportCaller) DryRun() bool { return c.dryRun } func (*sheetImportCaller) Fields() string { return "" } func (*sheetImportCaller) JQ() string { return "" } func fastSheetImportConfig() importFlowConfig { cfg := sheetImportFlowConfig() cfg.poll.maxPolls = 3 cfg.poll.interval = func(int) time.Duration { return 0 } cfg.poll.wait = func(context.Context, time.Duration) error { return nil } return cfg } func executeSheetImportCommand(t *testing.T, caller *sheetImportCaller, cfg importFlowConfig, args ...string) (string, error) { t.Helper() previousDeps := deps previousArgs := os.Args t.Cleanup(func() { deps = previousDeps os.Args = previousArgs SetHTTPPutFile(nil) }) InitDeps(caller) var output bytes.Buffer deps.Out.w = &output deps.Out.errW = &output os.Args = append([]string{"dws", "sheet"}, args...) root := newSheetImportCmdWithConfig(cfg) root.SilenceErrors = true root.SilenceUsage = true root.SetArgs(args) err := root.Execute() return output.String(), err } func writeImportFixture(t *testing.T, ext string) string { t.Helper() filePath := filepath.Join(t.TempDir(), "sales."+ext) if err := os.WriteFile(filePath, []byte("test workbook"), 0o600); err != nil { t.Fatalf("write fixture: %v", err) } return filePath } func TestSheetImportRejectsInvalidInputBeforeRemoteCall(t *testing.T) { tests := []struct { name string fileExt string args func(string) []string wantErr string }{ { name: "target is required", fileExt: "xlsx", args: func(path string) []string { return []string{"--file", path} }, wantErr: "--folder-token 与 --workspace 至少需要提供一个", }, { name: "only excel formats are accepted", fileExt: "csv", args: func(path string) []string { return []string{"--file", path, "--workspace", "ws1"} }, wantErr: `unsupported file format "csv", supported: xlsx, xls`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { caller := &sheetImportCaller{} _, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), tt.args(writeImportFixture(t, tt.fileExt))...) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %v, want containing %q", err, tt.wantErr) } if len(caller.calls) != 0 { t.Fatalf("remote calls = %d, want 0", len(caller.calls)) } }) } } func TestSheetImportRunsSharedDocImportFlow(t *testing.T) { caller := &sheetImportCaller{responses: map[string][]string{ "create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`}, "confirm_import": {`{"taskId":"task-1"}`}, "query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-1?from=test","documentName":"Sales","documentType":"1"}`}, }} SetHTTPPutFile(func(_ context.Context, uploadURL string, _ map[string]string, _ string, size int64) error { if uploadURL != "https://upload.example.test/object" || size <= 0 { return fmt.Errorf("unexpected upload url=%s size=%d", uploadURL, size) } return nil }) output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), "--file", writeImportFixture(t, "xlsx"), "--workspace", "workspace-1", "--name", "Sales", ) if err != nil { t.Fatalf("sheet import returned error: %v", err) } wantTools := []string{"create_import_session", "confirm_import", "query_import_task"} if len(caller.calls) != len(wantTools) { t.Fatalf("calls = %#v", caller.calls) } for i, call := range caller.calls { if call.server != "doc" || call.tool != wantTools[i] { t.Fatalf("call[%d] = %#v, want doc/%s", i, call, wantTools[i]) } } wantSession := map[string]any{ "fileName": "Sales", "suffix": "xlsx", "fileSize": int64(len("test workbook")), "workspaceId": "workspace-1", } if !reflect.DeepEqual(caller.calls[0].args, wantSession) { t.Fatalf("session args = %#v, want %#v", caller.calls[0].args, wantSession) } if !strings.Contains(output, `"nodeId": "node-1"`) || !strings.Contains(output, `"success": true`) { t.Fatalf("output missing success contract:\n%s", output) } } func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) { filePath := writeImportFixture(t, "xlsx") legacy, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(), "--file", filePath, "--workspace", "workspace-1", "--name", "Sales") if err != nil { t.Fatalf("legacy sheet import dry-run returned error: %v", err) } leaf, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(), "create", "--file", filePath, "--workspace", "workspace-1", "--name", "Sales") if err != nil { t.Fatalf("sheet import create dry-run returned error: %v", err) } if leaf != legacy { t.Fatalf("create leaf output differs from legacy entry:\nlegacy:\n%s\nleaf:\n%s", legacy, leaf) } root := newSheetImportCmdWithConfig(fastSheetImportConfig()) create, _, err := root.Find([]string{"create"}) if err != nil || create == nil { t.Fatalf("find create leaf: command=%v err=%v", create, err) } if !create.Runnable() || create.HasSubCommands() { t.Fatalf("create command must be a runnable leaf: runnable=%v hasSubcommands=%v", create.Runnable(), create.HasSubCommands()) } } func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) { cfg := fastSheetImportConfig() cfg.poll.maxPolls = 2 caller := &sheetImportCaller{responses: map[string][]string{ "create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`}, "confirm_import": {`{"taskId":"task-1"}`}, "query_import_task": { `{"status":"processing"}`, `{"status":"processing"}`, }, }} SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) output, err := executeSheetImportCommand(t, caller, cfg, "--file", writeImportFixture(t, "xls"), "--folder-token", "folder-1") if err != nil { t.Fatalf("timeout should exit successfully, got %v", err) } for _, fragment := range []string{ `"success": false`, `"timed_out": true`, `"status": "processing"`, `"next_command": "dws sheet import get --task-id task-1"`, } { if !strings.Contains(output, fragment) { t.Fatalf("output missing %s:\n%s", fragment, output) } } } func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) { caller := &sheetImportCaller{responses: map[string][]string{ "query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2/","documentType":"1"}`}, }} output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), "get", "--task-id", "task-2") if err != nil { t.Fatalf("sheet import get returned error: %v", err) } if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "query_import_task" { t.Fatalf("calls = %#v", caller.calls) } if !reflect.DeepEqual(caller.calls[0].args, map[string]any{"taskId": "task-2"}) { t.Fatalf("args = %#v", caller.calls[0].args) } var result map[string]any jsonStart := strings.Index(output, "{") if jsonStart < 0 || json.Unmarshal([]byte(output[jsonStart:]), &result) != nil { t.Fatalf("invalid JSON output:\n%s", output) } if result["nodeId"] != "node-2" { t.Fatalf("nodeId = %#v, want node-2", result["nodeId"]) } } func TestDocImportConfigPreservesExistingContract(t *testing.T) { cfg := docImportFlowConfig() for _, ext := range []string{"docx", "doc", "xlsx", "xls", "md", "txt", "xmind", "mark"} { if !cfg.supportedFormats[ext] { t.Errorf("doc import no longer supports %s", ext) } } if cfg.requireTarget || cfg.includeNodeID || cfg.timeoutAsResult { t.Fatalf("doc import compatibility changed: %#v", cfg) } } func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) { previousDeps := deps previousArgs := os.Args t.Cleanup(func() { deps = previousDeps os.Args = previousArgs }) caller := &sheetImportCaller{dryRun: true} InitDeps(caller) var output bytes.Buffer deps.Out.w = &output filePath := writeImportFixture(t, "md") os.Args = []string{"dws", "doc", "import", "--file", filePath} root := newDocCommand() root.SilenceErrors = true root.SilenceUsage = true root.SetArgs([]string{"import", "--file", filePath}) if err := root.Execute(); err != nil { t.Fatalf("doc import dry-run changed behavior: %v", err) } if len(caller.calls) != 0 { t.Fatalf("dry-run made %d remote calls", len(caller.calls)) } if !strings.Contains(output.String(), "导入本地文件为在线文档") || !strings.Contains(output.String(), "md") { t.Fatalf("unexpected dry-run output:\n%s", output.String()) } } func TestExtractNodeIDFromDocURL(t *testing.T) { tests := map[string]string{ "https://alidocs.dingtalk.com/i/nodes/node-1": "node-1", "https://alidocs.dingtalk.com/i/nodes/node-2/?from=test": "node-2", "https://alidocs.dingtalk.com/i/nodes/node-3#sheet=Sheet1": "node-3", "": "", } for rawURL, want := range tests { if got := extractNodeIDFromDocURL(rawURL); got != want { t.Errorf("extractNodeIDFromDocURL(%q) = %q, want %q", rawURL, got, want) } } }