Files
dingtalk-workspace-cli/internal/helpers/devapp_robot_connect_test.go
T
修雨 f5bdb7118a feat(devapp): add robot connect to fold bot linking into the devapp command set
Bring the channel-aware Stream linking ("建联") from PR #407 into the devapp
command tree as `dws devapp robot connect`, so the full lifecycle
(create app -> create robot -> link to a local agent) lives under one
`dws devapp` domain instead of a separate `dws connect` command.

- Reuse, not duplicate: provisioning ("建号") stays on devapp's existing
  `robot create/submit/result`; this commit ports only the linking half.
- connect_stream.go: the Go-native in-process Stream forwarder + channel
  agent registry, ported verbatim from #407 (package helpers, self-contained).
- devapp_connect.go: trimmed channel routing (resolveConnectChannel /
  launchConnector / buildConnectPlan / ...) plus the new subcommand. The
  standalone `connect` cobra command, `connect start` and `connect bot create`
  are intentionally NOT brought over — the capability is consolidated under
  `dws devapp robot`.
- Credentials: `--robot-client-id` / `--robot-client-secret` for an existing
  robot, or `--unified-app-id` to auto-fetch via devapp's credentials get.
  The flags are named `robot-client-*` (not `client-id`/`client-secret`) to
  avoid shadowing the global OAuth client-override persistent flags.
- Adds the dingtalk-stream-sdk-go v0.9.1 dependency.
- Skill docs (devapp.md, robot.md mono+multi) document `robot connect`.

Known follow-up (marked in code + docs): the get_open_dev_app_credentials
response field names used by `--unified-app-id` auto-fetch are not yet
confirmed against the real gateway; the path degrades safely (empty -> fall
back to explicit flags) and must be verified against pre-prod before relying
on it.
2026-06-10 10:06:31 +08:00

151 lines
5.2 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"
"strings"
"testing"
)
// TestDevAppRobotConnectRegistered confirms `devapp robot connect` is wired into
// the robot subtree alongside the (reused) provisioning commands.
func TestDevAppRobotConnectRegistered(t *testing.T) {
root := newDevAppCommand(&captureRunner{})
if _, _, err := root.Find([]string{"robot", "connect"}); err != nil {
t.Fatalf("devapp robot connect not registered: %v", err)
}
// Provisioning stays under robot (reused, not duplicated by connect).
for _, name := range []string{"create", "submit", "result"} {
if _, _, err := root.Find([]string{"robot", name}); err != nil {
t.Fatalf("devapp robot %s missing: %v", name, err)
}
}
}
// TestDevAppRobotConnectValidation covers the credential / channel guards on the
// dry-run path (which never launches the Stream connector).
func TestDevAppRobotConnectValidation(t *testing.T) {
cases := []struct {
name string
args []string
wantErr string
wantJSON []string // substrings expected in successful dry-run output
}{
{
name: "no credentials and no unified-app-id",
args: []string{"--channel", "claudecode", "--dry-run"},
wantErr: "需要 --robot-client-id/--robot-client-secret",
},
{
name: "unknown channel",
args: []string{"--channel", "nope", "--robot-client-id", "a", "--robot-client-secret", "b", "--dry-run"},
wantErr: "未知渠道",
},
{
name: "explicit credentials dry-run emits plan",
args: []string{"--channel", "claudecode", "--robot-client-id", "id1", "--robot-client-secret", "sec1", "--dry-run"},
wantJSON: []string{"\"credentialSource\"", "flag:--robot-client-id/--robot-client-secret", "stream-bridge", "\"clientId\""},
},
{
name: "unified-app-id dry-run skips credentials get",
args: []string{"--channel", "qoderwork", "--unified-app-id", "UAID", "--dry-run"},
wantJSON: []string{"credentials get, skipped in dry-run", "\"unifiedAppId\""},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newDevAppTestRoot(&captureRunner{})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"devapp", "robot", "connect"}, tc.args...))
err := root.Execute()
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("want error containing %q, got err=%v out=%s", tc.wantErr, err, out.String())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v\noutput:\n%s", err, out.String())
}
for _, sub := range tc.wantJSON {
if !strings.Contains(out.String(), sub) {
t.Fatalf("output missing %q:\n%s", sub, out.String())
}
}
})
}
}
// TestDevAppFetchCredentials checks that --unified-app-id credential resolution
// reuses get_open_dev_app_credentials and extracts clientId/clientSecret from the
// MCP envelope.
func TestDevAppFetchCredentials(t *testing.T) {
runner := &devAppResponseRunner{response: map[string]any{
"content": map[string]any{
"result": map[string]any{
"clientId": "ck-123",
"clientSecret": "cs-456",
},
},
}}
root := newDevAppTestRoot(runner)
connectCmd, _, err := root.Find([]string{"devapp", "robot", "connect"})
if err != nil {
t.Fatalf("find connect: %v", err)
}
connectCmd.SetContext(context.Background())
id, secret, err := devAppFetchCredentials(runner, connectCmd, "UAID-1")
if err != nil {
t.Fatalf("devAppFetchCredentials error: %v", err)
}
if id != "ck-123" || secret != "cs-456" {
t.Fatalf("got id=%q secret=%q, want ck-123/cs-456", id, secret)
}
if runner.last.Tool != "get_open_dev_app_credentials" {
t.Fatalf("Tool = %q, want get_open_dev_app_credentials", runner.last.Tool)
}
if runner.last.CanonicalProduct != devAppProduct {
t.Fatalf("CanonicalProduct = %q, want %q", runner.last.CanonicalProduct, devAppProduct)
}
if got := runner.last.Params["unifiedAppId"]; got != "UAID-1" {
t.Fatalf("unifiedAppId param = %v, want UAID-1", got)
}
}
// TestDevAppConnectUnwrap covers the envelope descent and the appKey/appSecret
// field fallbacks.
func TestDevAppConnectUnwrap(t *testing.T) {
// appKey/appSecret fallback when clientId/clientSecret absent.
payload := devAppConnectUnwrap(map[string]any{
"result": map[string]any{"appKey": "ak", "appSecret": "as"},
})
if got := devAppConnectFirst(payload, "clientId", "appKey"); got != "ak" {
t.Fatalf("clientId fallback = %q, want ak", got)
}
if got := devAppConnectFirst(payload, "clientSecret", "appSecret"); got != "as" {
t.Fatalf("clientSecret fallback = %q, want as", got)
}
// nil / missing tolerated.
if got := devAppConnectFirst(nil, "clientId"); got != "" {
t.Fatalf("nil map = %q, want empty", got)
}
}