Files
dingtalk-workspace-cli/internal/compat/mail_hooks_test.go
539d10f80f feat: 合并 pre-mcp-discovery 分支能力到 main(服务发现保持生产) (#391)
将 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>
2026-06-02 21:04:12 +08:00

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)
}
}