Files
dingtalk-workspace-cli/internal/helpers/connect_card_test.go
2026-07-15 11:41:30 +08:00

342 lines
12 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 (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
)
// cardAPIRecorder fakes the DingTalk card endpoints and records every call.
type cardAPIRecorder struct {
mu sync.Mutex
calls []string // "METHOD path"
bodies []map[string]any // parsed request bodies, same order
tokens int // accessToken request count
fail map[string]int // "METHOD path" -> HTTP status to return
}
func newCardAPIServer(t *testing.T) (*cardAPIRecorder, *httptest.Server) {
t.Helper()
rec := &cardAPIRecorder{fail: map[string]int{}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var body map[string]any
_ = json.Unmarshal(raw, &body)
key := r.Method + " " + r.URL.Path
rec.mu.Lock()
if r.URL.Path == "/v1.0/oauth2/accessToken" {
rec.tokens++
} else {
rec.calls = append(rec.calls, key)
rec.bodies = append(rec.bodies, body)
}
status := rec.fail[key]
rec.mu.Unlock()
if status != 0 {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"code":"boom"}`))
return
}
if r.URL.Path == "/v1.0/oauth2/accessToken" {
_, _ = w.Write([]byte(`{"accessToken":"tok-1","expireIn":7200}`))
return
}
_, _ = w.Write([]byte(`{"success":true}`))
}))
t.Cleanup(srv.Close)
return rec, srv
}
func withCardAPIBase(t *testing.T, base string) {
t.Helper()
old := dingtalkCardAPIBase
dingtalkCardAPIBase = base
oldGap, oldRepair := aiCardFrameGap, aiCardRepairDelay
aiCardFrameGap, aiCardRepairDelay = 0, 0
t.Cleanup(func() {
dingtalkCardAPIBase = old
aiCardFrameGap, aiCardRepairDelay = oldGap, oldRepair
})
}
func groupCallback() *chatbot.BotCallbackDataModel {
return &chatbot.BotCallbackDataModel{
ConversationId: "cid-group-1",
ConversationType: "2",
SenderStaffId: "staff-1",
}
}
// TestAICardCreateFinalizeSequence verifies the hermes-contract happy path:
// create (empty content = Thinking) → deliver → ONE finalized streaming frame.
func TestAICardCreateFinalizeSequence(t *testing.T) {
rec, srv := newCardAPIServer(t)
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
card, err := c.createAndDeliver(context.Background(), groupCallback())
if err != nil {
t.Fatalf("createAndDeliver: %v", err)
}
if !strings.HasPrefix(card.outTrackID, "dws_") {
t.Fatalf("outTrackID = %q, want dws_ prefix", card.outTrackID)
}
if err := c.finalize(context.Background(), card, "你好,**答案是 42**"); err != nil {
t.Fatalf("finalize: %v", err)
}
wantSeq := []string{
"POST /v1.0/card/instances",
"POST /v1.0/card/instances/deliver",
"PUT /v1.0/card/instances", // INPUTING + content
"PUT /v1.0/card/streaming", // finalize frame
"PUT /v1.0/card/instances", // FINISHED
}
if strings.Join(rec.calls, ",") != strings.Join(wantSeq, ",") {
t.Fatalf("call sequence = %v, want %v", rec.calls, wantSeq)
}
if rec.tokens != 1 {
t.Fatalf("token requests = %d, want 1 (cached)", rec.tokens)
}
// create: hermes template + empty "content" param (renders Thinking)
create := rec.bodies[0]
if create["cardTemplateId"] != defaultAICardTemplateID || create["callbackType"] != "STREAM" {
t.Fatalf("create payload wrong: %v", create)
}
pm := create["cardData"].(map[string]any)["cardParamMap"].(map[string]any)
if _, ok := pm["config"]; !ok {
t.Fatalf("create cardParamMap = %v, want config key (openclaw contract)", pm)
}
// deliver: group space + robotCode
deliver := rec.bodies[1]
if deliver["openSpaceId"] != "dtv1.card//IM_GROUP.cid-group-1" {
t.Fatalf("deliver openSpaceId = %v", deliver["openSpaceId"])
}
if deliver["imGroupOpenDeliverModel"].(map[string]any)["robotCode"] != "ding-client" {
t.Fatalf("deliver robotCode missing: %v", deliver)
}
// INPUTING then FINISHED flowStatus around a finalized streaming frame
statusOf := func(b map[string]any) string {
pm := b["cardData"].(map[string]any)["cardParamMap"].(map[string]any)
v, _ := pm["flowStatus"].(string)
return v
}
if statusOf(rec.bodies[2]) != aiCardFlowInputing {
t.Fatalf("flowStatus[2] = %v, want INPUTING", statusOf(rec.bodies[2]))
}
stream := rec.bodies[3]
if stream["key"] != "msgContent" || stream["isFinalize"] != true || stream["isError"] != false {
t.Fatalf("finalize frame wrong: %v", stream)
}
if statusOf(rec.bodies[4]) != aiCardFlowFinished {
t.Fatalf("flowStatus[4] = %v, want FINISHED", statusOf(rec.bodies[4]))
}
}
// TestAICardDeliverOneToOne checks 1:1 messages deliver into the IM_ROBOT
// space with the hermes shape (spaceType only, no robotCode).
func TestAICardDeliverOneToOne(t *testing.T) {
rec, srv := newCardAPIServer(t)
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
data := &chatbot.BotCallbackDataModel{ConversationType: "1", SenderStaffId: "staff-9"}
if _, err := c.createAndDeliver(context.Background(), data); err != nil {
t.Fatalf("createAndDeliver: %v", err)
}
deliver := rec.bodies[1]
if deliver["openSpaceId"] != "dtv1.card//IM_ROBOT.staff-9" {
t.Fatalf("deliver openSpaceId = %v, want IM_ROBOT.staff-9", deliver["openSpaceId"])
}
model := deliver["imRobotOpenDeliverModel"].(map[string]any)
if model["spaceType"] != "IM_ROBOT" || model["robotCode"] != "ding-client" {
t.Fatalf("imRobotOpenDeliverModel = %v", model)
}
}
// TestAICardCreateFailure checks errors surface so callers fall back to plain
// replies (never silently lost), and that missing staffId in 1:1 is rejected.
func TestAICardCreateFailure(t *testing.T) {
rec, srv := newCardAPIServer(t)
withCardAPIBase(t, srv.URL)
rec.fail["POST /v1.0/card/instances"] = 500
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
if _, err := c.createAndDeliver(context.Background(), groupCallback()); err == nil {
t.Fatal("want error when card create fails")
}
rec.fail = map[string]int{}
if _, err := c.createAndDeliver(context.Background(),
&chatbot.BotCallbackDataModel{ConversationType: "1"}); err == nil {
t.Fatal("want error for 1:1 without senderStaffId")
}
}
// TestAICardMarkFailed checks the stuck-Thinking remedy: a finalized error
// frame, errors swallowed.
func TestAICardMarkFailed(t *testing.T) {
rec, srv := newCardAPIServer(t)
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
c.markFailed(context.Background(), &aiCardInstance{outTrackID: "dws_x"})
last := rec.bodies[len(rec.bodies)-1]
pm := last["cardData"].(map[string]any)["cardParamMap"].(map[string]any)
if pm["flowStatus"] != aiCardFlowFailed {
t.Fatalf("markFailed frame wrong: %v", last)
}
}
// TestRobotConnectReplyCardFlag checks the flag default and dry-run surface.
func TestRobotConnectReplyCardFlag(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{"default: no template -> text + emotions", nil, "thinking/done表态"},
{"template configured -> ai-card", []string{"--card-template", "tpl-1.schema"}, `"replyStyle": "ai-card"`},
{"public alias -> ai-card", []string{"--card-template", "public"}, `"replyStyle": "ai-card"`},
{"explicit off", []string{"--reply-card=false"}, `"replyStyle": "text/markdown"`},
} {
t.Run(tc.name, func(t *testing.T) {
root := newDevAppTestRoot(&captureRunner{})
var out strings.Builder
root.SetOut(&out)
root.SetErr(&out)
args := append([]string{"dev", "connect",
"--channel", "claudecode",
"--robot-client-id", "a", "--robot-client-secret", "b", "--dry-run"}, tc.args...)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v\n%s", err, out.String())
}
if !strings.Contains(out.String(), tc.want) {
t.Fatalf("missing %q in:\n%s", tc.want, out.String())
}
})
}
}
// TestAICardEmotions covers the Thinking chip contract: reply on receive,
// recall+Done on completion (POST /v1.0/robot/emotion/*).
func TestAICardEmotions(t *testing.T) {
rec, srv := newCardAPIServer(t)
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
if err := c.markThinking(context.Background(), "cid-1", "msg-1"); err != nil {
t.Fatalf("markThinking: %v", err)
}
c.swapThinkingToDone(context.Background(), "cid-1", "msg-1")
wantSeq := []string{
"POST /v1.0/robot/emotion/reply", // Thinking
"POST /v1.0/robot/emotion/recall", // Thinking recalled
"POST /v1.0/robot/emotion/reply", // Done
}
if strings.Join(rec.calls, ",") != strings.Join(wantSeq, ",") {
t.Fatalf("call sequence = %v, want %v", rec.calls, wantSeq)
}
first := rec.bodies[0]
if first["emotionName"] != "🤔Thinking" || first["openMsgId"] != "msg-1" ||
first["robotCode"] != "ding-client" {
t.Fatalf("thinking payload wrong: %v", first)
}
te := first["textEmotion"].(map[string]any)
if te["emotionId"] != "2659900" || te["backgroundId"] != "im_bg_1" || te["text"] != "🤔Thinking" {
t.Fatalf("textEmotion wrong: %v", te)
}
if rec.bodies[2]["emotionName"] != "🥳Done" {
t.Fatalf("done payload wrong: %v", rec.bodies[2])
}
// Missing ids must error (e.g. payloads without MsgId).
if err := c.markThinking(context.Background(), "", "msg-1"); err == nil {
t.Fatal("want error for missing conversation id")
}
}
// TestAICardCustomTemplate checks --card-template plumbs through to create.
func TestAICardCustomTemplate(t *testing.T) {
rec, srv := newCardAPIServer(t)
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", "my-own-template.schema")
if _, err := c.createAndDeliver(context.Background(), groupCallback()); err != nil {
t.Fatalf("createAndDeliver: %v", err)
}
if rec.bodies[0]["cardTemplateId"] != "my-own-template.schema" {
t.Fatalf("cardTemplateId = %v, want custom template", rec.bodies[0]["cardTemplateId"])
}
}
func TestAICardOTOAndRepairCoverage(t *testing.T) {
rec, srv := newCardAPIServer(t)
withCardAPIBase(t, srv.URL)
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
if err := c.sendOTOText(context.Background(), nil, "ignored"); err != nil {
t.Fatal(err)
}
if err := c.sendOTOText(context.Background(), []string{"u1", "u2"}, "hello"); err != nil {
t.Fatal(err)
}
c.repair(context.Background(), &aiCardInstance{outTrackID: "track"}, "fixed")
cancelled, cancel := context.WithCancel(context.Background())
cancel()
oldDelay := aiCardRepairDelay
aiCardRepairDelay = time.Hour
c.repair(cancelled, &aiCardInstance{outTrackID: "track"}, "ignored")
aiCardRepairDelay = oldDelay
if len(rec.calls) < 2 || rec.calls[0] != "POST /v1.0/robot/oToMessages/batchSend" {
t.Fatalf("OTO/repair calls = %#v", rec.calls)
}
}
func TestAtMentionSendGroupReplyCoverage(t *testing.T) {
_, srv := newCardAPIServer(t)
withCardAPIBase(t, srv.URL)
poller := &atMentionPoller{
clientID: "ding-client",
channel: "test",
botClient: newAICardClient("ding-client", "ding-secret", ""),
}
if err := poller.sendGroupReply(context.Background(), "conversation", "reply"); err != nil {
t.Fatal(err)
}
oldBase := dingtalkCardAPIBase
dingtalkCardAPIBase = "%"
if err := poller.sendGroupReply(context.Background(), "conversation", "reply"); err == nil {
t.Fatal("invalid group reply URL succeeded")
}
dingtalkCardAPIBase = oldBase
}