将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main, 服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。 主要内容: - 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355) - DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390) - envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化 - skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等) - product/oa 进一步对齐 wukong 排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com); 以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。 Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com> Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
425 lines
13 KiB
Go
425 lines
13 KiB
Go
// 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 compat
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// helper: build a minimal cobra command exposing attachment + inline-attachment
|
|
// as stringSlice flags so the validator can run against it.
|
|
func newMailSendStub() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "send"}
|
|
cmd.Flags().StringSlice("attachment", nil, "attachment paths")
|
|
cmd.Flags().StringSlice("inline-attachment", nil, "inline attachment paths")
|
|
return cmd
|
|
}
|
|
|
|
func TestValidateMailAttachmentFiles_AcceptsRealFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "ok.pdf")
|
|
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
cmd := newMailSendStub()
|
|
if err := cmd.Flags().Set("attachment", path); err != nil {
|
|
t.Fatalf("set flag: %v", err)
|
|
}
|
|
if err := validateMailAttachmentFiles(cmd, nil); err != nil {
|
|
t.Fatalf("expected nil for existing file, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateMailAttachmentFiles_RejectsMissingFile(t *testing.T) {
|
|
cmd := newMailSendStub()
|
|
if err := cmd.Flags().Set("attachment", "/tmp/this_does_not_exist_xyz123.pdf"); err != nil {
|
|
t.Fatalf("set flag: %v", err)
|
|
}
|
|
err := validateMailAttachmentFiles(cmd, nil)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for missing attachment")
|
|
}
|
|
if !strings.Contains(err.Error(), "cannot read attachment") {
|
|
t.Fatalf("unexpected error wording: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateMailAttachmentFiles_RejectsDirectory(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cmd := newMailSendStub()
|
|
if err := cmd.Flags().Set("attachment", dir); err != nil {
|
|
t.Fatalf("set flag: %v", err)
|
|
}
|
|
err := validateMailAttachmentFiles(cmd, nil)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for directory attachment")
|
|
}
|
|
if !strings.Contains(err.Error(), "is a directory") {
|
|
t.Fatalf("unexpected error wording: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateMailAttachmentFiles_RejectsMissingInline(t *testing.T) {
|
|
cmd := newMailSendStub()
|
|
if err := cmd.Flags().Set("inline-attachment", "/tmp/no_such_image_zyx999.png"); err != nil {
|
|
t.Fatalf("set flag: %v", err)
|
|
}
|
|
err := validateMailAttachmentFiles(cmd, nil)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for missing inline attachment")
|
|
}
|
|
if !strings.Contains(err.Error(), "cannot read inline attachment") {
|
|
t.Fatalf("unexpected error wording: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateMailAttachmentFiles_NoFlagsRegistered(t *testing.T) {
|
|
// e.g. a command without either flag (defensive) should not blow up.
|
|
cmd := &cobra.Command{Use: "noop"}
|
|
if err := validateMailAttachmentFiles(cmd, nil); err != nil {
|
|
t.Fatalf("expected nil when flags absent, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ── search_mail_users email fallback ──────────────────────────
|
|
|
|
type fakeMailboxRunner struct {
|
|
called bool
|
|
gotTool string
|
|
resp map[string]any
|
|
err error
|
|
}
|
|
|
|
func (f *fakeMailboxRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
|
f.called = true
|
|
f.gotTool = inv.Tool
|
|
if f.err != nil {
|
|
return executor.Result{}, f.err
|
|
}
|
|
return executor.Result{Invocation: inv, Response: f.resp}, nil
|
|
}
|
|
|
|
func newMailUserSearchStub() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "search"}
|
|
cmd.Flags().String("email", "", "mailbox")
|
|
cmd.Flags().String("keyword", "", "keyword")
|
|
return cmd
|
|
}
|
|
|
|
func TestMailUserSearchEmailFallback_NoOpWhenEmailProvided(t *testing.T) {
|
|
runner := &fakeMailboxRunner{resp: map[string]any{
|
|
"emailAccounts": []any{
|
|
map[string]any{"email": "first@example.com"},
|
|
},
|
|
}}
|
|
pre := newMailUserSearchEmailFallback(runner)
|
|
cmd := newMailUserSearchStub()
|
|
if err := cmd.Flags().Set("email", "user@example.com"); err != nil {
|
|
t.Fatalf("set: %v", err)
|
|
}
|
|
if err := pre(cmd, nil); err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if runner.called {
|
|
t.Fatal("runner should not be invoked when --email is set")
|
|
}
|
|
if got, _ := cmd.Flags().GetString("email"); got != "user@example.com" {
|
|
t.Fatalf("email mutated: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMailUserSearchEmailFallback_FillsFromFirstMailbox(t *testing.T) {
|
|
runner := &fakeMailboxRunner{resp: map[string]any{
|
|
"emailAccounts": []any{
|
|
map[string]any{"email": "first@example.com", "type": "ENTERPRISE"},
|
|
map[string]any{"email": "second@example.com"},
|
|
},
|
|
}}
|
|
pre := newMailUserSearchEmailFallback(runner)
|
|
cmd := newMailUserSearchStub()
|
|
if err := pre(cmd, nil); err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if !runner.called || runner.gotTool != "list_user_mailboxes" {
|
|
t.Fatalf("expected list_user_mailboxes call, runner=%+v", runner)
|
|
}
|
|
got, _ := cmd.Flags().GetString("email")
|
|
if got != "first@example.com" {
|
|
t.Fatalf("fallback email = %q, want first@example.com", got)
|
|
}
|
|
}
|
|
|
|
func TestMailUserSearchEmailFallback_HandlesWrappedResultEnvelope(t *testing.T) {
|
|
runner := &fakeMailboxRunner{resp: map[string]any{
|
|
"result": map[string]any{
|
|
"emailAccounts": []any{
|
|
map[string]any{"email": "wrapped@example.com"},
|
|
},
|
|
},
|
|
}}
|
|
pre := newMailUserSearchEmailFallback(runner)
|
|
cmd := newMailUserSearchStub()
|
|
if err := pre(cmd, nil); err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if got, _ := cmd.Flags().GetString("email"); got != "wrapped@example.com" {
|
|
t.Fatalf("email = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMailUserSearchEmailFallback_NoMailboxReturnsValidation(t *testing.T) {
|
|
runner := &fakeMailboxRunner{resp: map[string]any{"emailAccounts": []any{}}}
|
|
pre := newMailUserSearchEmailFallback(runner)
|
|
cmd := newMailUserSearchStub()
|
|
err := pre(cmd, nil)
|
|
if err == nil {
|
|
t.Fatal("expected validation error when no mailbox returned")
|
|
}
|
|
if !strings.Contains(err.Error(), "could not auto-detect a mailbox") {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMailUserSearchEmailFallback_PropagatesRunnerError(t *testing.T) {
|
|
runner := &fakeMailboxRunner{err: errors.New("boom")}
|
|
pre := newMailUserSearchEmailFallback(runner)
|
|
cmd := newMailUserSearchStub()
|
|
err := pre(cmd, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "auto-detect mailbox") {
|
|
t.Fatalf("expected wrapped runner error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ── installMailHook composition ────────────────────────────────
|
|
|
|
func TestInstallMailHook_NoOpForOtherProduct(t *testing.T) {
|
|
cmd := newMailSendStub()
|
|
originalCalled := false
|
|
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
|
|
installMailHook(cmd, "chat", "send_email", nil)
|
|
if err := cmd.PreRunE(cmd, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !originalCalled {
|
|
t.Fatal("original PreRunE should still run")
|
|
}
|
|
// Setting a bad attachment should NOT fail since hook is no-op for chat.
|
|
if err := cmd.Flags().Set("attachment", "/tmp/no_such_path_for_chat.bin"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmd.PreRunE(cmd, nil); err != nil {
|
|
t.Fatalf("non-mail product must not validate attachments: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInstallMailHook_ChainsExistingPreRunE(t *testing.T) {
|
|
cmd := newMailSendStub()
|
|
originalCalled := false
|
|
cmd.PreRunE = func(*cobra.Command, []string) error {
|
|
originalCalled = true
|
|
return nil
|
|
}
|
|
installMailHook(cmd, "mail", "send_email", nil)
|
|
if err := cmd.PreRunE(cmd, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !originalCalled {
|
|
t.Fatal("original PreRunE was dropped")
|
|
}
|
|
}
|
|
|
|
func TestInstallMailHook_BailsIfChainedPreRunEFails(t *testing.T) {
|
|
cmd := newMailSendStub()
|
|
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
|
|
installMailHook(cmd, "mail", "send_email", nil)
|
|
err := cmd.PreRunE(cmd, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "original boom") {
|
|
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInstallMailHook_AttachesToAllAttachmentTools(t *testing.T) {
|
|
tools := []string{
|
|
"send_email",
|
|
"create_reply_draft",
|
|
"create_replyall_draft",
|
|
"create_forward_draft",
|
|
"create_draft",
|
|
"update_draft",
|
|
}
|
|
for _, tool := range tools {
|
|
cmd := newMailSendStub()
|
|
installMailHook(cmd, "mail", tool, nil)
|
|
if err := cmd.Flags().Set("attachment", "/tmp/no_such_file_for_"+tool); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := cmd.PreRunE(cmd, nil)
|
|
if err == nil {
|
|
t.Fatalf("tool %s: expected attachment validation to fire", tool)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstallMailHook_NilCmdSafe(t *testing.T) {
|
|
// Defensive: should not panic.
|
|
installMailHook(nil, "mail", "send_email", nil)
|
|
}
|
|
|
|
// ── extractFirstMailboxEmail wrap handling ─────────────────────
|
|
|
|
func TestExtractFirstMailboxEmail_HandlesRuntimeRunnerWrapping(t *testing.T) {
|
|
// Mirrors runtimeRunner.executeInvocation: Response is wrapped as
|
|
// {"endpoint": "...", "content": {emailAccounts: [...]}}.
|
|
wrapped := map[string]any{
|
|
"endpoint": "https://mcp.example.com",
|
|
"content": map[string]any{
|
|
"emailAccounts": []any{
|
|
map[string]any{"email": "real@example.com", "type": "PERSONAL"},
|
|
},
|
|
},
|
|
}
|
|
if got := extractFirstMailboxEmail(wrapped); got != "real@example.com" {
|
|
t.Fatalf("wrapped extraction failed: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractFirstMailboxEmail_HandlesNestedResultUnderContent(t *testing.T) {
|
|
wrapped := map[string]any{
|
|
"content": map[string]any{
|
|
"result": map[string]any{
|
|
"emailAccounts": []any{
|
|
map[string]any{"email": "nested@example.com"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if got := extractFirstMailboxEmail(wrapped); got != "nested@example.com" {
|
|
t.Fatalf("nested extraction failed: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractFirstMailboxEmail_TextBlockFallback(t *testing.T) {
|
|
wrapped := map[string]any{
|
|
"content": []any{
|
|
map[string]any{"type": "text", "text": `{"emailAccounts":[{"email":"text@example.com"}]}`},
|
|
},
|
|
}
|
|
if got := extractFirstMailboxEmail(wrapped); got != "text@example.com" {
|
|
t.Fatalf("text-block extraction failed: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractFirstMailboxEmail_ReturnsEmptyForEmptyAccounts(t *testing.T) {
|
|
if extractFirstMailboxEmail(map[string]any{"content": map[string]any{"emailAccounts": []any{}}}) != "" {
|
|
t.Fatal("expected empty for no accounts")
|
|
}
|
|
if extractFirstMailboxEmail(nil) != "" {
|
|
t.Fatal("expected empty for nil")
|
|
}
|
|
}
|
|
|
|
// ── pickMailboxForUserSearch tier preference ───────────────────
|
|
|
|
func TestPickMailboxForUserSearch_PrefersEnterprise(t *testing.T) {
|
|
resp := map[string]any{
|
|
"content": map[string]any{
|
|
"emailAccounts": []any{
|
|
map[string]any{"email": "personal@dingtalk.com", "type": "PERSONAL"},
|
|
map[string]any{"email": "biz@corp.com", "type": "ENTERPRISE"},
|
|
},
|
|
},
|
|
}
|
|
email, kind := pickMailboxForUserSearch(resp)
|
|
if email != "biz@corp.com" {
|
|
t.Fatalf("expected enterprise pick, got %q", email)
|
|
}
|
|
if kind != mailboxKindEnterprise {
|
|
t.Fatalf("expected enterprise kind, got %v", kind)
|
|
}
|
|
}
|
|
|
|
func TestPickMailboxForUserSearch_FallsBackToPersonalWithKind(t *testing.T) {
|
|
resp := map[string]any{
|
|
"content": map[string]any{
|
|
"emailAccounts": []any{
|
|
map[string]any{"email": "personal@dingtalk.com", "type": "PERSONAL"},
|
|
},
|
|
},
|
|
}
|
|
email, kind := pickMailboxForUserSearch(resp)
|
|
if email != "personal@dingtalk.com" {
|
|
t.Fatalf("expected personal email, got %q", email)
|
|
}
|
|
if kind != mailboxKindPersonal {
|
|
t.Fatalf("expected personal kind, got %v", kind)
|
|
}
|
|
}
|
|
|
|
func TestPickMailboxForUserSearch_EmptyResponse(t *testing.T) {
|
|
email, kind := pickMailboxForUserSearch(nil)
|
|
if email != "" || kind != mailboxKindUnknown {
|
|
t.Fatalf("expected empty unknown, got email=%q kind=%v", email, kind)
|
|
}
|
|
}
|
|
|
|
func TestMailUserSearchEmailFallback_PersonalMailboxEmitsSkipMarker(t *testing.T) {
|
|
runner := &fakeMailboxRunner{resp: map[string]any{
|
|
"content": map[string]any{
|
|
"emailAccounts": []any{
|
|
map[string]any{"email": "personal@dingtalk.com", "type": "PERSONAL"},
|
|
},
|
|
},
|
|
}}
|
|
pre := newMailUserSearchEmailFallback(runner)
|
|
cmd := newMailUserSearchStub()
|
|
err := pre(cmd, nil)
|
|
if err == nil {
|
|
t.Fatal("expected validation error to short-circuit personal mailbox")
|
|
}
|
|
if !strings.Contains(err.Error(), "PAT_MEDIUM_RISK_NO_PERMISSION") {
|
|
t.Fatalf("missing skip marker, got %v", err)
|
|
}
|
|
// Confirm we did NOT mutate --email when refusing.
|
|
if got, _ := cmd.Flags().GetString("email"); got != "" {
|
|
t.Fatalf("email should remain unset, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMailUserSearchEmailFallback_PrefersEnterpriseOverPersonal(t *testing.T) {
|
|
runner := &fakeMailboxRunner{resp: map[string]any{
|
|
"content": map[string]any{
|
|
"emailAccounts": []any{
|
|
map[string]any{"email": "p@dingtalk.com", "type": "PERSONAL"},
|
|
map[string]any{"email": "biz@corp.com", "type": "ENTERPRISE"},
|
|
},
|
|
},
|
|
}}
|
|
pre := newMailUserSearchEmailFallback(runner)
|
|
cmd := newMailUserSearchStub()
|
|
if err := pre(cmd, nil); err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if got, _ := cmd.Flags().GetString("email"); got != "biz@corp.com" {
|
|
t.Fatalf("fallback email = %q, want biz@corp.com", got)
|
|
}
|
|
}
|