Files
dingtalk-workspace-cli/internal/helpers/report_deprecation_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

264 lines
9.1 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 helpers
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// recordingRunner captures invocations so tests can assert that a wrapper
// did or did not forward the call to the underlying handler.
type recordingRunner struct {
calls []executor.Invocation
err error
}
func (r *recordingRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
r.calls = append(r.calls, inv)
if r.err != nil {
return executor.Result{}, r.err
}
return executor.Result{Invocation: inv, Response: map[string]any{"success": true}}, nil
}
// withReportDeprecationWarning is the contract every deprecated alias wraps
// its RunE with. The wrapper must:
// - emit a stderr line starting with "[deprecated]"
// - name both the old path the user invoked and the new canonical path
// - call the wrapped handler exactly once and propagate its error
func TestWithReportDeprecationWarning_PrintsStderrAndForwards(t *testing.T) {
t.Parallel()
called := 0
innerErr := errors.New("inner sentinel")
wrapped := withReportDeprecationWarning("sent", "outbox list", func(*cobra.Command, []string) error {
called++
return innerErr
})
var stderr bytes.Buffer
cmd := &cobra.Command{Use: "sent"}
cmd.SetErr(&stderr)
gotErr := wrapped(cmd, nil)
if !errors.Is(gotErr, innerErr) {
t.Fatalf("expected inner error to propagate, got %v", gotErr)
}
if called != 1 {
t.Fatalf("inner handler called %d times, want 1", called)
}
msg := stderr.String()
if !strings.Contains(msg, "[deprecated]") {
t.Fatalf("stderr missing [deprecated] marker: %q", msg)
}
if !strings.Contains(msg, "dws report sent") {
t.Fatalf("stderr missing old path: %q", msg)
}
if !strings.Contains(msg, "dws report outbox list") {
t.Fatalf("stderr missing new canonical path: %q", msg)
}
}
// newReportCreatedCommand must be a leaf attached to the helpers report root,
// with the deprecation wrapper already on RunE pointing at outbox list. The
// shape — leaf, not group — matters because MergeHardcodedLeaves only grafts
// helper leaves that have no same-named envelope counterpart.
func TestNewReportCreatedCommand_IsLeafWithDeprecationWrapper(t *testing.T) {
t.Parallel()
runner := &recordingRunner{}
cmd := newReportCreatedCommand(runner)
if cmd == nil {
t.Fatal("nil command returned")
}
if cmd.Use != "created" {
t.Fatalf("Use = %q, want %q", cmd.Use, "created")
}
if cmd.HasSubCommands() {
t.Fatalf("created should be a leaf, not a group")
}
if !strings.Contains(cmd.Short, "[deprecated]") {
t.Fatalf("short missing [deprecated]: %q", cmd.Short)
}
if cmd.RunE == nil {
t.Fatal("RunE not wired")
}
// Exercise RunE end-to-end with --dry-run via the root --dry-run flag
// so the runner records the invocation without making any network call.
var stderr bytes.Buffer
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", true, "")
root.AddCommand(cmd)
cmd.SetErr(&stderr)
cmd.SetOut(&bytes.Buffer{})
root.SetArgs([]string{"created", "--cursor", "0", "--size", "5", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-08T00:00:00+08:00"})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stderr.String(), "[deprecated] `dws report created`") {
t.Fatalf("stderr missing old path marker: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "dws report outbox list") {
t.Fatalf("stderr missing new path: %q", stderr.String())
}
}
// AttachReportLegacyInboxAlias targets the dynamic-built `report inbox` group:
// it must register the legacy flags (start/end/cursor/size/limit/...) and
// wire a deprecation-wrapped RunE. Subcommands (e.g. inbox list) must stay
// reachable and unmodified.
func TestAttachReportLegacyInboxAlias_EnrichesGroupInPlace(t *testing.T) {
t.Parallel()
// Build a stand-in dynamic tree mirroring the envelope shape: a `report`
// root with an `inbox` group whose only child is `list`. The list leaf
// already has the canonical flags from envelope-side registration.
listLeaf := &cobra.Command{
Use: "list",
RunE: func(*cobra.Command, []string) error { return nil },
}
listLeaf.Flags().String("start", "", "")
listLeaf.Flags().String("end", "", "")
inboxGroup := &cobra.Command{
Use: "inbox",
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
inboxGroup.AddCommand(listLeaf)
reportRoot := &cobra.Command{Use: "report"}
reportRoot.AddCommand(inboxGroup)
runner := &recordingRunner{}
AttachReportLegacyInboxAlias([]*cobra.Command{reportRoot}, runner)
// Flags must have been added to the GROUP (not the list child).
for _, name := range []string{"start", "end", "cursor", "size", "limit", "sender-user-ids"} {
if inboxGroup.Flags().Lookup(name) == nil {
t.Errorf("inbox group missing --%s after hook", name)
}
}
// list child stays intact — hook must NOT have replaced it.
if listLeaf.Parent() != inboxGroup {
t.Errorf("list child detached from inbox group")
}
// Group RunE must now be the deprecation-wrapped handler. Verify by
// invoking it directly with the required flags set on the group.
var stderr bytes.Buffer
inboxGroup.SetErr(&stderr)
inboxGroup.SetOut(&bytes.Buffer{})
if err := inboxGroup.Flags().Set("start", "2026-03-01T00:00:00+08:00"); err != nil {
t.Fatalf("set start: %v", err)
}
if err := inboxGroup.Flags().Set("end", "2026-03-08T00:00:00+08:00"); err != nil {
t.Fatalf("set end: %v", err)
}
// Wire a root with --dry-run so the runner records without network IO.
dwsRoot := &cobra.Command{Use: "dws"}
dwsRoot.PersistentFlags().Bool("dry-run", true, "")
dwsRoot.AddCommand(reportRoot)
if err := inboxGroup.RunE(inboxGroup, nil); err != nil {
t.Fatalf("inbox RunE failed: %v", err)
}
if !strings.Contains(stderr.String(), "[deprecated] `dws report inbox`") {
t.Fatalf("stderr missing deprecation marker: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "dws report inbox list") {
t.Fatalf("stderr missing new path: %q", stderr.String())
}
}
// AttachReportLegacyInboxAlias on a tree without `report inbox` must be a
// no-op — callers should be able to invoke the hook unconditionally.
func TestAttachReportLegacyInboxAlias_NoopWhenInboxAbsent(t *testing.T) {
t.Parallel()
other := &cobra.Command{Use: "other"}
report := &cobra.Command{Use: "report"}
// No inbox child.
runner := &recordingRunner{}
// Should not panic / not error.
AttachReportLegacyInboxAlias([]*cobra.Command{nil, other, report}, runner)
if report.HasSubCommands() {
t.Errorf("expected no children synthesised; got %d", len(report.Commands()))
}
}
// reportSentRunE / reportListRunE are extracted so that the legacy aliases
// (sent / created and list / inbox) can each wrap them. Sanity-check that
// reusing the extracted handler with a dry-run runner produces a
// recordingRunner invocation for the right MCP tool name.
func TestReportSentRunE_InvokesCanonicalTool(t *testing.T) {
t.Parallel()
runner := &recordingRunner{}
cmd := newReportSentCommand(runner)
// Disable the wrapper to test the inner body directly.
cmd.RunE = reportSentRunE(runner)
var out bytes.Buffer
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", true, "")
root.AddCommand(cmd)
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"sent", "--cursor", "0", "--size", "5", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-08T00:00:00+08:00"})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
// EchoRunner-style: dry-run path short-circuits before runner.Run, so
// recordingRunner.calls stays empty. We instead confirm the dry-run
// payload is written to stdout.
if !strings.Contains(out.String(), "get_send_report_list") {
t.Fatalf("stdout missing canonical tool name: %q", out.String())
}
}
func TestReportListRunE_InvokesCanonicalTool(t *testing.T) {
t.Parallel()
runner := &recordingRunner{}
cmd := newReportListCommand(runner)
cmd.RunE = reportListRunE(runner)
var out bytes.Buffer
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", true, "")
root.AddCommand(cmd)
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"list", "--cursor", "0", "--size", "5", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-08T00:00:00+08:00"})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(out.String(), "get_received_report_list") {
t.Fatalf("stdout missing canonical tool name: %q", out.String())
}
}