Files
dingtalk-workspace-cli/internal/app/schema_contract_test.go

615 lines
21 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.
package app
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
var (
fullSchemaSnapshotOnce sync.Once
fullSchemaSnapshot cli.SchemaCatalogSnapshot
fullSchemaSnapshotErr error
)
// fullSchemaSnapshotForTest runs the complete source-to-delivery invariant
// once per test binary. The returned snapshot is a shared read-only fixture;
// callers still build their own Cobra root when they need executable command
// pointers. This preserves every full-Catalog assertion without paying the
// multi-gigabyte generation/validation cost three times under -race.
func fullSchemaSnapshotForTest(t testing.TB) cli.SchemaCatalogSnapshot {
t.Helper()
fullSchemaSnapshotOnce.Do(func() {
resolved, err := cli.ResolveSchemaBuild(NewRootCommand())
if err != nil {
fullSchemaSnapshotErr = err
return
}
fullSchemaSnapshot, fullSchemaSnapshotErr = cli.BuildSchemaCatalogSnapshot(resolved, cli.SchemaCatalogBuildOptions{})
})
if fullSchemaSnapshotErr != nil {
t.Fatalf("build shared final Schema snapshot: %v", fullSchemaSnapshotErr)
}
return fullSchemaSnapshot
}
func TestDeliverySchemaContractMapsToExecutableTree(t *testing.T) {
root := NewRootCommand()
effective, err := cli.BuildEffectiveCommandRegistry(root)
if err != nil {
t.Fatal(err)
}
expected := make(map[string]bool)
for _, command := range effective.Commands {
if command.Visibility == cli.SchemaVisibilityPublic {
expected[command.CanonicalPath] = true
}
}
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetArgs([]string{"schema", "--all", "--format", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("execute delivery schema --all: %v; stderr=%s", err, stderr.String())
}
var payload struct {
Products []struct {
Tools []struct {
CanonicalPath string `json:"canonical_path"`
} `json:"tools"`
} `json:"products"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode delivery schema --all: %v", err)
}
actual := make(map[string]bool)
var duplicates []string
for _, product := range payload.Products {
for _, tool := range product.Tools {
canonical := strings.TrimSpace(tool.CanonicalPath)
if canonical == "" {
t.Fatal("delivery schema --all contains an empty canonical path")
}
if actual[canonical] {
duplicates = append(duplicates, canonical)
}
actual[canonical] = true
}
}
if len(duplicates) > 0 {
sort.Strings(duplicates)
t.Fatalf("delivery schema --all contains duplicate canonicals: %v", duplicates)
}
var missing, extra []string
for canonical := range expected {
if !actual[canonical] {
missing = append(missing, canonical)
}
}
for canonical := range actual {
if !expected[canonical] {
extra = append(extra, canonical)
}
}
if len(missing) > 0 || len(extra) > 0 {
sort.Strings(missing)
sort.Strings(extra)
t.Fatalf("runtime-assembled Schema canonical set differs from EffectiveCommandRegistry: missing=%v extra=%v", missing, extra)
}
}
func TestGeneratedSchemaContractMapsToExecutableTree(t *testing.T) {
root := NewRootCommand()
snapshot := fullSchemaSnapshotForTest(t)
bindings, err := cli.LoadSchemaParameterBindings()
if err != nil {
t.Fatalf("LoadSchemaParameterBindings() error = %v", err)
}
if len(snapshot.Tools) == 0 {
t.Fatal("generated Schema Catalog contains no tools")
}
for canonicalPath := range bindings {
if _, ok := snapshot.Tools[canonicalPath]; !ok {
t.Errorf("parameter bindings reference canonical %q that is absent from the final generated Schema", canonicalPath)
}
}
for canonicalPath, definition := range snapshot.Tools {
cliPath := schemaContractString(definition["primary_cli_path"])
if cliPath == "" {
cliPath = schemaContractString(definition["cli_path"])
}
command := exactCommandForTest(root, cliPath)
if command == nil {
for _, alias := range schemaContractStringSlice(definition["aliases"]) {
if command = exactCommandForTest(root, alias); command != nil {
break
}
}
}
if command == nil {
t.Errorf("%s has no executable CLI path %q", canonicalPath, cliPath)
continue
}
for parameterName, rawParameter := range schemaContractMap(definition["parameters"]) {
flag := schemaContractCommandFlag(command, parameterName)
if flag == nil {
t.Errorf("%s maps parameter %q to missing flag on %q", canonicalPath, parameterName, command.CommandPath())
continue
}
if got, want := schemaContractFlagDefault(flag), schemaContractString(rawParameter["default"]); want != got {
t.Errorf("%s parameter %q default = %q, Cobra --help default = %q", canonicalPath, parameterName, want, got)
}
}
for flagName, propertyName := range bindings[canonicalPath] {
flag := schemaContractCommandFlag(command, flagName)
if flag == nil || flag.Hidden {
t.Errorf("%s binding --%s references a missing or hidden public flag", canonicalPath, flagName)
continue
}
parameter := schemaContractMap(definition["parameters"])[flagName]
if parameter == nil || schemaContractString(parameter["property"]) != propertyName {
t.Errorf("%s binding --%s -> %s is absent from generated Catalog", canonicalPath, flagName, propertyName)
}
}
}
}
func TestRuntimeSchemaParameterMetadataMapsToGeneratedCatalog(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
for canonicalPath, metadata := range cli.RuntimeSchemaParameterMetadataDefinitions() {
tool := snapshot.Tools[canonicalPath]
if tool == nil {
t.Errorf("parameter metadata references unknown tool %q", canonicalPath)
continue
}
parameters, _ := tool["parameters"].(map[string]any)
parameter := func(flagName string) map[string]any {
value, _ := parameters[flagName].(map[string]any)
if value == nil {
t.Errorf("%s parameter metadata references unknown flag --%s", canonicalPath, flagName)
}
return value
}
for _, flagName := range metadata.Inherited {
parameter(flagName)
}
for _, flagName := range metadata.Required {
if value := parameter(flagName); value != nil {
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "required", true)
}
}
for flagName, want := range metadata.RequiredWhen {
if value := parameter(flagName); value != nil {
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "required_when", want)
}
}
for flagName, want := range metadata.Formats {
if value := parameter(flagName); value != nil {
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "format", want)
}
}
for flagName, want := range metadata.Examples {
if value := parameter(flagName); value != nil {
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "example", want)
}
}
for flagName, want := range metadata.Enums {
if value := parameter(flagName); value != nil {
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "enum", want)
}
}
}
}
// assertRuntimeSchemaMetadataCandidate verifies the field-level resolver
// contract without assuming which source wins. Typed runtime metadata must
// remain visible as a candidate, resolution must select exactly one candidate,
// and the final payload/envelope must agree with that selected candidate.
func assertRuntimeSchemaMetadataCandidate(t testing.TB, canonicalPath, flagName string, parameter map[string]any, field string, typedValue any) {
t.Helper()
fieldProvenance, _ := parameter["field_provenance"].(map[string]any)
provenance, _ := fieldProvenance[field].(map[string]any)
if provenance == nil {
t.Errorf("%s --%s %s has no final resolver provenance", canonicalPath, flagName, field)
return
}
foundTypedCandidate := false
var selected map[string]any
selectedCount := 0
for _, candidate := range schemaContractObjectSlice(provenance["candidates"]) {
if candidate["source"] == "typed_parameter_metadata" && schemaContractJSONEqual(candidate["value"], typedValue) {
foundTypedCandidate = true
}
if isSelected, _ := candidate["selected"].(bool); isSelected {
selected = candidate
selectedCount++
}
}
if !foundTypedCandidate {
t.Errorf("%s --%s %s provenance has no typed_parameter_metadata candidate with value %#v", canonicalPath, flagName, field, typedValue)
}
if selectedCount != 1 {
t.Errorf("%s --%s %s provenance selected candidates = %d, want 1", canonicalPath, flagName, field, selectedCount)
return
}
if got, want := parameter[field], selected["value"]; !schemaContractJSONEqual(got, want) {
t.Errorf("%s --%s final %s = %#v, selected provenance value = %#v", canonicalPath, flagName, field, got, want)
}
if got, want := provenance["value"], selected["value"]; !schemaContractJSONEqual(got, want) {
t.Errorf("%s --%s %s provenance value = %#v, selected candidate value = %#v", canonicalPath, flagName, field, got, want)
}
if got, want := provenance["precedence"], selected["precedence"]; got != want {
t.Errorf("%s --%s %s provenance precedence = %#v, selected candidate precedence = %#v", canonicalPath, flagName, field, got, want)
}
}
func schemaContractJSONEqual(left, right any) bool {
leftJSON, leftErr := json.Marshal(left)
rightJSON, rightErr := json.Marshal(right)
return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON)
}
func schemaContractObjectSlice(value any) []map[string]any {
switch typed := value.(type) {
case []map[string]any:
return typed
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if object, ok := item.(map[string]any); ok {
out = append(out, object)
}
}
return out
default:
return nil
}
}
func schemaContractFlagDefault(flag *pflag.Flag) string {
if flag == nil {
return ""
}
value := strings.TrimSpace(flag.DefValue)
if value == "" || value == "0s" || value == "[]" || value == "{}" {
return ""
}
switch flag.Value.Type() {
case "bool":
if value == "false" {
return ""
}
case "int", "int8", "int16", "int32", "int64", "float32", "float64":
if value == "0" {
return ""
}
}
return value
}
func schemaContractCommandFlag(command *cobra.Command, name string) *pflag.Flag {
if command == nil {
return nil
}
if flag := command.Flags().Lookup(name); flag != nil {
return flag
}
for current := command; current != nil; current = current.Parent() {
if flag := current.PersistentFlags().Lookup(name); flag != nil {
return flag
}
}
return nil
}
func schemaContractMap(value any) map[string]map[string]any {
switch typed := value.(type) {
case map[string]map[string]any:
return typed
case map[string]any:
out := make(map[string]map[string]any, len(typed))
for key, item := range typed {
if object, ok := item.(map[string]any); ok {
out[key] = object
}
}
return out
default:
return nil
}
}
func schemaContractString(value any) string {
switch typed := value.(type) {
case string:
return typed
case nil:
return ""
default:
return fmt.Sprint(typed)
}
}
func schemaContractStringSlice(value any) []string {
switch typed := value.(type) {
case []string:
return typed
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
if text, ok := item.(string); ok {
out = append(out, text)
}
}
return out
default:
return nil
}
}
// schemaContractPayloadForBoundCanonicals builds a small test fixture by
// selecting already validated BoundCommand entries before Schema assembly.
// Production generation deliberately has no post-assembly subset option: its
// delivered set must always equal the public EffectiveCommandRegistry set.
func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command, canonicals ...string) cli.SchemaSnapshotPayload {
t.Helper()
effective, err := cli.BuildEffectiveCommandRegistry(root)
if err != nil {
t.Fatalf("build effective CommandRegistry: %v", err)
}
fixtureRegistry := cli.EffectiveCommandRegistry{Commands: make([]cli.CommandSpec, 0, len(canonicals))}
for _, canonical := range canonicals {
command, ok := effective.ByCanonical[canonical]
if !ok {
t.Fatalf("effective fixture has no canonical %s", canonical)
}
fixtureRegistry.Commands = append(fixtureRegistry.Commands, command)
}
fixture, err := cli.BindEffectiveCommandRegistry(root, fixtureRegistry)
if err != nil {
t.Fatalf("bind synthetic effective CommandRegistry: %v", err)
}
registry, err := cli.AssembleSchemaRegistryFromBound(fixture)
if err != nil {
t.Fatalf("assemble synthetic bound Schema registry: %v", err)
}
payload, err := registry.ToSnapshotPayload()
if err != nil {
t.Fatalf("render synthetic bound Schema registry: %v", err)
}
return payload
}
func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
"chat.send_personal_message",
"chat.send_robot_message",
"chat.reply_personal_message",
)
send, ok := snapshot.Tools["chat.send_personal_message"]
if !ok || schemaContractString(send["primary_cli_path"]) != "chat message send" {
t.Fatalf("send definition = %#v", send)
}
reply, ok := snapshot.Tools["chat.reply_personal_message"]
if !ok || schemaContractString(reply["primary_cli_path"]) != "chat message reply" {
t.Fatalf("reply definition = %#v", reply)
}
interfaceRef, _ := reply["interface_ref"].(map[string]any)
if schemaContractString(interfaceRef["product_id"]) != "chat" || schemaContractString(interfaceRef["rpc_name"]) != "send_personal_message" {
t.Fatalf("reply interface = %#v", interfaceRef)
}
if _, exists := snapshot.Tools["chat.upload_conversation_file"]; exists {
t.Fatal("downlined chat file upload must not be advertised in Schema")
}
botReply := snapshot.Tools["chat.send_robot_message"]
botParams := schemaContractMap(botReply["parameters"])
if got := schemaContractString(botParams["reply"]["property"]); got != "referenceOpenMessageId" {
t.Fatalf("bot --reply property = %q", got)
}
if got := schemaContractString(botParams["ref-sender"]["property"]); got != "srcMsgSendOpenDingTalkId" {
t.Fatalf("bot --ref-sender property = %q", got)
}
if got, ok := botParams["title"]["required"].(bool); !ok || got {
t.Fatalf("bot --title required = %#v, want false for conditional Markdown input", botParams["title"]["required"])
}
assertSchemaContractConstraintGroup(t, botReply, "require_together", []string{"reply", "ref-sender"})
}
func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
root := NewRootCommand()
snapshot := schemaContractPayloadForBoundCanonicals(t, root, "calendar.remove_calendar_participant")
tool := snapshot.Tools["calendar.remove_calendar_participant"]
// Runtime does not gate this path today; Schema confirmation must follow metadata.runtime_gate.
if got := tool["confirmation"]; got != "not_required" {
t.Fatalf("calendar attendee delete confirmation = %#v, want not_required", got)
}
if got := tool["risk"]; got != "medium" {
t.Fatalf("calendar attendee delete risk = %#v, want medium", got)
}
}
func TestPromptingWritesRequireUserConfirmation(t *testing.T) {
wantEffects := map[string]string{
"attendance.class_create": "write",
"attendance.class_update": "write",
"doc.delete_comment": "write",
"doc.version_revert": "write",
"drive.publish_set": "write",
"drive.publish_unset": "write",
"sheet.chart_delete": "write",
"sheet.delete_pivot_table": "write",
}
wantRisks := map[string]string{
"attendance.class_create": "medium",
"attendance.class_update": "medium",
"doc.delete_comment": "medium",
"doc.version_revert": "medium",
"drive.publish_set": "medium",
"drive.publish_unset": "medium",
"sheet.chart_delete": "medium",
"sheet.delete_pivot_table": "medium",
}
wantSources := map[string]string{
"attendance.class_create": "corecmd.contract",
"attendance.class_update": "corecmd.contract",
"doc.delete_comment": "corecmd.contract",
"doc.version_revert": "corecmd.contract",
"drive.publish_set": "corecmd.contract",
"drive.publish_unset": "corecmd.contract",
"sheet.chart_delete": "corecmd.contract",
"sheet.delete_pivot_table": "corecmd.contract",
}
canonicals := make([]string, 0, len(wantEffects))
for canonical := range wantEffects {
canonicals = append(canonicals, canonical)
}
sort.Strings(canonicals)
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
for _, canonical := range canonicals {
tool := snapshot.Tools[canonical]
if got := tool["effect"]; got != wantEffects[canonical] {
t.Errorf("%s effect = %#v, want %s", canonical, got, wantEffects[canonical])
}
if got := tool["risk"]; got != wantRisks[canonical] {
t.Errorf("%s risk = %#v, want %s", canonical, got, wantRisks[canonical])
}
if got := tool["confirmation"]; got != "user_required" {
t.Errorf("%s confirmation = %#v, want user_required", canonical, got)
}
provenance, _ := tool["field_provenance"].(map[string]any)
for _, field := range []string{"effect", "risk", "confirmation"} {
selected, _ := provenance[field].(map[string]any)
if got := selected["source"]; got != wantSources[canonical] {
t.Errorf("%s %s provenance source = %#v, want %s", canonical, field, got, wantSources[canonical])
}
}
}
}
func TestNewMainCommandInterfaceConversionsReachFinalSchema(t *testing.T) {
type conversion struct {
canonical string
flag string
cliType string
interfaceType string
}
wants := []conversion{
{canonical: "chat.list_message_favorites", flag: "size", cliType: "integer", interfaceType: "string"},
{canonical: "doc.update_comment", flag: "mention", cliType: "string", interfaceType: "array"},
{canonical: "sheet.create_pivot_table", flag: "properties", cliType: "string", interfaceType: "object"},
{canonical: "sheet.table_put", flag: "sheets", cliType: "string", interfaceType: "array"},
{canonical: "sheet.update_pivot_table", flag: "properties", cliType: "string", interfaceType: "object"},
}
canonicals := make([]string, 0, len(wants))
for _, want := range wants {
canonicals = append(canonicals, want.canonical)
}
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
for _, want := range wants {
tool := snapshot.Tools[want.canonical]
parameters, _ := tool["parameters"].(map[string]any)
parameter, _ := parameters[want.flag].(map[string]any)
if got := schemaContractString(parameter["type"]); got != want.cliType {
t.Errorf("%s --%s CLI type = %q, want %q", want.canonical, want.flag, got, want.cliType)
}
if got := schemaContractString(parameter["interface_type"]); got != want.interfaceType {
t.Errorf("%s --%s interface_type = %q, want %q", want.canonical, want.flag, got, want.interfaceType)
}
}
if got := snapshot.Tools["sheet.create_pivot_table"]["idempotency"]; got != "non_idempotent" {
t.Errorf("sheet.create_pivot_table idempotency = %#v, want non_idempotent", got)
}
}
func TestDefaultedPaginationSchemaFlagsAreOptional(t *testing.T) {
wants := map[string][]string{
"chat.search_messages_by_time_range": {"limit"},
"oa.list_user_visible_process": {"cursor", "limit"},
"report.get_received_report_list": {"cursor", "size"},
"todo.get_user_todos_in_current_org": {"page"},
}
canonicals := make([]string, 0, len(wants)+1)
for canonicalPath := range wants {
canonicals = append(canonicals, canonicalPath)
}
canonicals = append(canonicals, "aitable.section_reorder")
sort.Strings(canonicals)
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
for canonicalPath, flags := range wants {
parameters := schemaContractMap(snapshot.Tools[canonicalPath]["parameters"])
for _, flagName := range flags {
if parameters[flagName]["required"] == true {
t.Errorf("%s --%s has a CLI default and must remain optional", canonicalPath, flagName)
}
}
}
targetIndex := schemaContractMap(snapshot.Tools["aitable.section_reorder"]["parameters"])["target-index"]
if targetIndex["required"] != true {
t.Error("aitable.section_reorder --target-index uses -1 as a sentinel and must remain required")
}
}
func TestPATSchemaKeepsCLIContract(t *testing.T) {
root := NewRootCommand()
payload := schemaContractPayloadForBoundCanonicals(t, root, "pat.batch_grant")
tool := payload.Tools["pat.batch_grant"]
parameters, _ := tool["parameters"].(map[string]any)
grantType, _ := parameters["grant-type"].(map[string]any)
if grantType["default"] != "permanent" {
t.Fatalf("grant-type default = %#v", grantType["default"])
}
positionals, _ := tool["positionals"].([]any)
if len(positionals) != 1 {
t.Fatalf("PAT positionals = %#v", tool["positionals"])
}
positional, _ := positionals[0].(map[string]any)
if positional["name"] != "scope" || positional["variadic"] != true {
t.Fatalf("PAT positionals = %#v", tool["positionals"])
}
}
func exactCommandForTest(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(strings.TrimSpace(path))
if len(parts) > 0 && parts[0] == root.Name() {
parts = parts[1:]
}
current := root
for _, name := range parts {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == name {
next = child
break
}
}
if next == nil {
return nil
}
current = next
}
if current == root {
return nil
}
return current
}