Files
multica/server/internal/integrations/slack/outbound_test.go
beast 387e4fee5d fix(channels): keep direct chat replies in Multica (MUL-4988) (#5645)
Classify outbound replies by task origin (chat_input_task_id) before consulting a channel binding, so web/mobile direct-chat completions and failures stay inside Multica even when the session was originally created from Lark or Slack. Channel-created tasks continue to deliver. Closes #5644.
2026-07-20 14:00:54 +08:00

177 lines
5.3 KiB
Go

package slack
import (
"context"
"encoding/base64"
"encoding/json"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/integrations/channel"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func uid(b byte) pgtype.UUID {
var u pgtype.UUID
u.Bytes[0] = b
u.Valid = true
return u
}
type fakeOutboundQueries struct {
binding db.ChannelChatSessionBinding
bindingErr error
inst db.ChannelInstallation
instErr error
task db.AgentTaskQueue
taskErr error
}
func (f *fakeOutboundQueries) GetAgentTask(context.Context, pgtype.UUID) (db.AgentTaskQueue, error) {
return f.task, f.taskErr
}
func (f *fakeOutboundQueries) GetChannelChatSessionBindingBySession(context.Context, db.GetChannelChatSessionBindingBySessionParams) (db.ChannelChatSessionBinding, error) {
return f.binding, f.bindingErr
}
func (f *fakeOutboundQueries) GetChannelInstallation(context.Context, db.GetChannelInstallationParams) (db.ChannelInstallation, error) {
return f.inst, f.instErr
}
type fakeSender struct {
called int
got channel.OutboundMessage
}
func (f *fakeSender) Send(_ context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
f.called++
f.got = out
return channel.SendResult{MessageID: "1.1"}, nil
}
// slackInstallConfigJSON builds an installation config blob with base64 tokens
// (a nil Decrypter treats the decoded bytes as plaintext).
func slackInstallConfigJSON() []byte {
b, _ := json.Marshal(map[string]string{
"app_id": "T1",
"bot_user_id": "UBOT",
"bot_token_encrypted": base64.StdEncoding.EncodeToString([]byte("xoxb-test")),
})
return b
}
func newTestOutbound(q outboundQueries, fs *fakeSender) *Outbound {
o := NewOutbound(q, nil, nil)
o.newSender = func(credentials) replySender { return fs }
return o
}
func chatDoneEvent(sessionID string, content string) events.Event {
return events.Event{
Type: protocol.EventChatDone,
ChatSessionID: sessionID,
Payload: protocol.ChatDonePayload{
TaskID: "00000000-0000-0000-0000-000000000002",
ChatSessionID: sessionID,
Content: content,
},
}
}
func TestOutbound_SkipsDirectChatTaskOnBoundSlackSession(t *testing.T) {
q := &fakeOutboundQueries{
task: db.AgentTaskQueue{ChatInputTaskID: uid(2)},
binding: db.ChannelChatSessionBinding{
InstallationID: uid(1),
ChannelChatID: "C123",
Config: []byte(`{"channel_id":"C123"}`),
},
inst: db.ChannelInstallation{ID: uid(1), Status: "active", Config: slackInstallConfigJSON()},
}
fs := &fakeSender{}
newTestOutbound(q, fs).handleEvent(chatDoneEvent("00000000-0000-0000-0000-000000000001", "private web reply"))
if fs.called != 0 {
t.Fatalf("sender called %d times, want 0 for a direct-chat task", fs.called)
}
}
func TestOutbound_PostsReplyToBoundSlackChannel(t *testing.T) {
q := &fakeOutboundQueries{
// Composite isolation key; real channel + reply thread come from config /
// last_thread_id.
binding: db.ChannelChatSessionBinding{
InstallationID: uid(1),
ChannelChatID: "C123:1111.0",
Config: []byte(`{"channel_id":"C123"}`),
LastThreadID: pgtype.Text{String: "1111.0", Valid: true},
},
inst: db.ChannelInstallation{ID: uid(1), Status: "active", Config: slackInstallConfigJSON()},
}
fs := &fakeSender{}
o := newTestOutbound(q, fs)
o.handleEvent(chatDoneEvent("00000000-0000-0000-0000-000000000001", "**all done**"))
if fs.called != 1 {
t.Fatalf("sender called %d times, want 1", fs.called)
}
if fs.got.ChatID != "C123" {
t.Errorf("ChatID = %q, want the real channel from config (not the composite key)", fs.got.ChatID)
}
if fs.got.ThreadID != "1111.0" {
t.Errorf("ThreadID = %q, want the recorded reply thread", fs.got.ThreadID)
}
if fs.got.Text != "**all done**" {
t.Errorf("Text = %q, want the raw content (Send applies mrkdwn)", fs.got.Text)
}
}
func TestOutbound_IgnoresNonSlackAndEmptyAndRevoked(t *testing.T) {
const sid = "00000000-0000-0000-0000-000000000001"
activeInst := db.ChannelInstallation{ID: uid(1), Status: "active", Config: slackInstallConfigJSON()}
boundBinding := db.ChannelChatSessionBinding{InstallationID: uid(1), ChannelChatID: "C1", Config: []byte(`{"channel_id":"C1"}`)}
cases := []struct {
name string
q *fakeOutboundQueries
evt events.Event
}{
{
name: "no slack binding (Feishu / web session)",
q: &fakeOutboundQueries{bindingErr: pgx.ErrNoRows},
evt: chatDoneEvent(sid, "hi"),
},
{
name: "empty completion content",
q: &fakeOutboundQueries{binding: boundBinding, inst: activeInst},
evt: chatDoneEvent(sid, ""),
},
{
name: "revoked installation",
q: &fakeOutboundQueries{binding: boundBinding, inst: db.ChannelInstallation{ID: uid(1), Status: "revoked", Config: slackInstallConfigJSON()}},
evt: chatDoneEvent(sid, "hi"),
},
{
name: "non-chat event (no session id)",
q: &fakeOutboundQueries{},
evt: chatDoneEvent("", "hi"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fs := &fakeSender{}
newTestOutbound(tc.q, fs).handleEvent(tc.evt)
if fs.called != 0 {
t.Errorf("%s: sender must not be called, got %d", tc.name, fs.called)
}
})
}
}