373 lines
16 KiB
Go
373 lines
16 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func commandFallbackJSON(entries string) []byte {
|
|
return []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":1,"entries":` + entries + `}`)
|
|
}
|
|
|
|
func validCommandFallbackEntry(from, to string) string {
|
|
return `{"from":"` + from + `","mode":"rewrite","to":"` + to + `","reviewed":true,"review_reason":"reviewed fixture"}`
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDecodeCommandPathFallbacksStrictContract(t *testing.T) {
|
|
valid := commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]`)
|
|
entries, err := decodeCommandPathFallbacks(valid)
|
|
if err != nil || len(entries) != 1 || entries[0].From != "chat +bad" || entries[0].To != "chat +good" {
|
|
t.Fatalf("decode valid = %#v, %v", entries, err)
|
|
}
|
|
entries[0].Candidates = append(entries[0].Candidates, "mutated")
|
|
cloned := cloneCommandPathFallbacks(entries)
|
|
cloned[0].Candidates[0] = "clone"
|
|
if entries[0].Candidates[0] != "mutated" {
|
|
t.Fatal("cloneCommandPathFallbacks aliases candidate storage")
|
|
}
|
|
|
|
tests := map[string]struct {
|
|
data []byte
|
|
want string
|
|
}{
|
|
"unknown field": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":"x","extra":true}]`),
|
|
want: "unknown field",
|
|
},
|
|
"wrong schema": {
|
|
data: []byte(`{"$schema":"wrong","version":1,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
|
want: "$schema",
|
|
},
|
|
"wrong version": {
|
|
data: []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":2,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
|
want: "unsupported",
|
|
},
|
|
"empty entries": {data: commandFallbackJSON(`[]`), want: "no entries"},
|
|
"unnormalized path": {
|
|
data: commandFallbackJSON(`[` + validCommandFallbackEntry(` chat +bad `, `chat +good`) + `]`),
|
|
want: "not a normalized",
|
|
},
|
|
"unreviewed": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":false,"review_reason":"x"}]`),
|
|
want: "reviewed=true",
|
|
},
|
|
"empty reason": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":" "}]`),
|
|
want: "reviewed=true",
|
|
},
|
|
"invalid mode": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"guess","reviewed":true,"review_reason":"x"}]`),
|
|
want: "invalid mode",
|
|
},
|
|
"rewrite candidates": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
|
want: "must not declare candidates",
|
|
},
|
|
"empty rewrite target": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","reviewed":true,"review_reason":"x"}]`),
|
|
want: "path is empty",
|
|
},
|
|
"ambiguous target": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
|
want: "must not declare to",
|
|
},
|
|
"ambiguous too small": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
|
want: "at least two",
|
|
},
|
|
"empty ambiguous candidate": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one",""],"reviewed":true,"review_reason":"x"}]`),
|
|
want: "path is empty",
|
|
},
|
|
"duplicate candidate": {
|
|
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one","chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
|
want: "repeats candidate",
|
|
},
|
|
"duplicate from": {
|
|
data: commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `,` + validCommandFallbackEntry(`chat +bad`, `chat +other`) + `]`),
|
|
want: "duplicate from",
|
|
},
|
|
"multiple documents": {
|
|
data: append(valid, []byte(` {}`)...),
|
|
want: "multiple JSON values",
|
|
},
|
|
}
|
|
for name, test := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := decodeCommandPathFallbacks(test.data); err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("decode error = %v, want containing %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) {
|
|
var schema map[string]any
|
|
if err := json.Unmarshal(embeddedCommandPathFallbacksSchemaJSON, &schema); err != nil {
|
|
t.Fatalf("decode embedded schema: %v", err)
|
|
}
|
|
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
|
|
t.Fatalf("schema header = %#v", schema)
|
|
}
|
|
entries, err := LoadCommandPathFallbacks()
|
|
if err != nil || len(entries) != 34 {
|
|
t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err)
|
|
}
|
|
if got, ok := LookupCommandPathFallback("dws chat +group-search"); !ok || got.To != "chat +chat-search" {
|
|
t.Fatalf("generated lookup = %#v, %v", got, ok)
|
|
}
|
|
if _, ok := LookupCommandPathFallback("chat +missing"); ok {
|
|
t.Fatal("generated lookup accepted unknown path")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
|
|
rewrites := map[string]string{
|
|
"chat +bot-list": "chat +chat-bots",
|
|
"chat +conversation-detail": "chat +conversation-info",
|
|
"chat +group-search": "chat +chat-search",
|
|
"chat +members": "chat +group-members",
|
|
"chat +group-member-list": "chat +group-members",
|
|
"chat +list-group-bots": "chat +chat-bots",
|
|
"chat +list-robot": "chat +chat-bots",
|
|
"chat +list-robots": "chat +chat-bots",
|
|
"chat +rename-group": "chat +chat-update",
|
|
"doc +create-version": "doc +history-save",
|
|
"doc +list-templates": "doc +template-list",
|
|
"doc +save-version": "doc +history-save",
|
|
"doc +search-template": "doc +template-search",
|
|
"doc +snapshot": "doc +history-save",
|
|
"doc +version-create": "doc +history-save",
|
|
}
|
|
for from, to := range rewrites {
|
|
entry, ok := LookupCommandPathFallback(from)
|
|
if !ok || entry.Mode != CommandPathFallbackRewrite || entry.To != to {
|
|
t.Errorf("rewrite %q = %#v, %v; want %q", from, entry, ok, to)
|
|
}
|
|
}
|
|
|
|
ambiguous := map[string][]string{
|
|
"chat +conversation-category-list": {"chat +category-list", "chat +category-list-conversations"},
|
|
"chat +conversation-group-list": {"chat +category-list-conversations", "chat +conversation-list"},
|
|
"chat +group-send-text": {"chat +send-to-group", "chat +messages-send"},
|
|
"chat +list-my-groups": {"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
|
|
"chat +message-list": {"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
|
|
"chat +read-single": {"chat +messages-list-direct", "chat +chat-messages"},
|
|
"chat +send": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
|
"chat +send-by-bot": {"chat +messages-send", "chat message send-by-bot"},
|
|
"chat +send-dm": {"chat +dm", "chat +messages-send"},
|
|
"chat +send-message": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
|
"chat +send-single": {"chat +dm", "chat +messages-send"},
|
|
"chat +send-text": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
|
"chat +send-to": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
|
"chat +send-file": {"chat +messages-send", "chat message send"},
|
|
"chat +send-image": {"chat +messages-send", "chat message send"},
|
|
"chat +send-media": {"chat +messages-send", "chat message send"},
|
|
"oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
|
|
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
|
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
|
|
}
|
|
for from, candidates := range ambiguous {
|
|
entry, ok := LookupCommandPathFallback(from)
|
|
if !ok || entry.Mode != CommandPathFallbackAmbiguous || !reflect.DeepEqual(entry.Candidates, candidates) {
|
|
t.Errorf("ambiguous %q = %#v, %v; want %v", from, entry, ok, candidates)
|
|
}
|
|
}
|
|
|
|
if entry, ok := LookupCommandPathFallback("chat +definitely-unknown"); ok {
|
|
t.Errorf("unreviewed path unexpectedly falls back: %#v", entry)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageReduceCommandPathFallbacksValidatesLiveTree(t *testing.T) {
|
|
root := commandFallbackTestRoot()
|
|
entries := []CommandPathFallback{
|
|
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good", Reviewed: true, ReviewReason: "fixture"},
|
|
{From: "chat group search", Mode: CommandPathFallbackRewrite, To: "chat search", Reviewed: true, ReviewReason: "fixture"},
|
|
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +other"}, Reviewed: true, ReviewReason: "fixture"},
|
|
{From: "chat +mixed", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat search"}, Reviewed: true, ReviewReason: "fixture"},
|
|
}
|
|
withCommandFallbackSource(t, entries)
|
|
got, err := ReduceCommandPathFallbacks(root)
|
|
if err != nil || len(got) != len(entries) {
|
|
t.Fatalf("ReduceCommandPathFallbacks() = %#v, %v", got, err)
|
|
}
|
|
got[2].Candidates[0] = "mutated"
|
|
again, err := ReduceCommandPathFallbacks(root)
|
|
if err != nil || again[2].Candidates[0] == "mutated" {
|
|
t.Fatalf("reduction did not clone candidates: %#v, %v", again, err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageReduceCommandPathFallbacksRejectsUnsafeMappings(t *testing.T) {
|
|
baseRoot := commandFallbackTestRoot
|
|
tests := map[string]struct {
|
|
entries []CommandPathFallback
|
|
mutate func(*cobra.Command)
|
|
want string
|
|
}{
|
|
"missing target": {
|
|
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +missing"}},
|
|
want: "does not exist",
|
|
},
|
|
"real source collision": {
|
|
entries: []CommandPathFallback{{From: "chat +other", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
|
want: "collides with a real Cobra command or alias",
|
|
},
|
|
"cobra alias collision": {
|
|
entries: []CommandPathFallback{{From: "chat +official-alias", Mode: CommandPathFallbackRewrite, To: "chat +other"}},
|
|
want: "collides with a real Cobra command or alias",
|
|
},
|
|
"cross service": {
|
|
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "mail +good"}},
|
|
want: "crosses service boundary",
|
|
},
|
|
"shortcut mismatch": {
|
|
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat search"}},
|
|
want: "disagree on +shortcut identity",
|
|
},
|
|
"chained rewrite": {
|
|
entries: []CommandPathFallback{
|
|
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +next"},
|
|
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +good"},
|
|
},
|
|
want: "chained fallbacks are forbidden",
|
|
},
|
|
"chained ambiguous candidate": {
|
|
entries: []CommandPathFallback{
|
|
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +next"}},
|
|
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +other"},
|
|
},
|
|
want: "is another fallback source",
|
|
},
|
|
"missing ambiguous candidate": {
|
|
entries: []CommandPathFallback{{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +missing"}}},
|
|
want: "does not exist",
|
|
},
|
|
"hidden target": {
|
|
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
|
mutate: func(root *cobra.Command) {
|
|
exactSchemaCommand(root, "chat +good").Hidden = true
|
|
},
|
|
want: "not a public runnable",
|
|
},
|
|
"target is alias": {
|
|
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +official-alias"}},
|
|
want: "must use canonical Cobra names",
|
|
},
|
|
"ambiguous source command": {
|
|
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
|
mutate: func(root *cobra.Command) {
|
|
chat := exactSchemaCommand(root, "chat")
|
|
chat.AddCommand(
|
|
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
|
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
|
)
|
|
},
|
|
want: "cannot be resolved safely",
|
|
},
|
|
"ambiguous target command": {
|
|
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +duplicate"}},
|
|
mutate: func(root *cobra.Command) {
|
|
chat := exactSchemaCommand(root, "chat")
|
|
chat.AddCommand(
|
|
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
|
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
|
)
|
|
},
|
|
want: "target \"chat +duplicate\" cannot be resolved safely",
|
|
},
|
|
"unsupported reduced mode": {
|
|
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackMode("invalid")}},
|
|
want: "unsupported mode",
|
|
},
|
|
}
|
|
for name, test := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
root := baseRoot()
|
|
if test.mutate != nil {
|
|
test.mutate(root)
|
|
}
|
|
for index := range test.entries {
|
|
test.entries[index].Reviewed = true
|
|
test.entries[index].ReviewReason = "fixture"
|
|
}
|
|
withCommandFallbackSource(t, test.entries)
|
|
if _, err := ReduceCommandPathFallbacks(root); err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("reduction error = %v, want containing %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
withCommandFallbackLoadError(t, errors.New("fixture load"))
|
|
if _, err := ReduceCommandPathFallbacks(baseRoot()); err == nil || !strings.Contains(err.Error(), "fixture load") {
|
|
t.Fatalf("load error = %v", err)
|
|
}
|
|
if _, err := ReduceCommandPathFallbacks(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
|
|
t.Fatalf("nil root error = %v", err)
|
|
}
|
|
if got := commandFallbackService(""); got != "" {
|
|
t.Fatalf("commandFallbackService(empty) = %q", got)
|
|
}
|
|
}
|
|
|
|
func commandFallbackTestRoot() *cobra.Command {
|
|
root := &cobra.Command{Use: "dws"}
|
|
chat := &cobra.Command{Use: "chat"}
|
|
good := &cobra.Command{Use: "+good", Aliases: []string{"+official-alias"}, Run: func(*cobra.Command, []string) {}}
|
|
chat.AddCommand(
|
|
good,
|
|
&cobra.Command{Use: "+other", Run: func(*cobra.Command, []string) {}},
|
|
&cobra.Command{Use: "search", Run: func(*cobra.Command, []string) {}},
|
|
)
|
|
group := &cobra.Command{Use: "group"}
|
|
group.AddCommand(cmdutil.HintSubCmd("search", "use chat search"))
|
|
chat.AddCommand(group)
|
|
mail := &cobra.Command{Use: "mail"}
|
|
mail.AddCommand(&cobra.Command{Use: "+good", Run: func(*cobra.Command, []string) {}})
|
|
root.AddCommand(chat, mail)
|
|
return root
|
|
}
|
|
|
|
func withCommandFallbackSource(t *testing.T, entries []CommandPathFallback) {
|
|
t.Helper()
|
|
old := loadReviewedCommandPathFallbacks
|
|
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) {
|
|
return cloneCommandPathFallbacks(entries), nil
|
|
}
|
|
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
|
}
|
|
|
|
func withCommandFallbackLoadError(t *testing.T, want error) {
|
|
t.Helper()
|
|
old := loadReviewedCommandPathFallbacks
|
|
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { return nil, want }
|
|
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
|
}
|
|
|
|
func TestCrossPlatformCoverageCommandPathFallbackGeneratedTableWellFormed(t *testing.T) {
|
|
entries := loadGeneratedCommandPathFallbacks()
|
|
if len(entries) == 0 {
|
|
t.Fatal("generated command path fallback table is empty")
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, entry := range entries {
|
|
if seen[entry.From] || !entry.Reviewed || strings.TrimSpace(entry.ReviewReason) == "" {
|
|
t.Fatalf("invalid generated entry: %#v", entry)
|
|
}
|
|
seen[entry.From] = true
|
|
}
|
|
if !reflect.DeepEqual(entries, cloneCommandPathFallbacks(entries)) {
|
|
t.Fatal("generated table clone changed values")
|
|
}
|
|
}
|