342 lines
12 KiB
Go
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
|
|
}
|