Files
2026-07-13 13:00:08 +08:00

356 lines
10 KiB
Go

package qq
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"reasonix/internal/bot"
"reasonix/internal/config"
)
func TestHandleDispatchDirectMessageUsesDirectChatType(t *testing.T) {
a := &adapter{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
msgCh: make(chan bot.InboundMessage, 1),
}
raw, err := json.Marshal(map[string]any{
"id": "msg-1",
"content": "hello",
"guild_id": "guild-1",
"author": map[string]string{
"id": "user-1",
"username": "user",
},
})
if err != nil {
t.Fatal(err)
}
a.handleDispatch(gatewayPayload{T: "DIRECT_MESSAGE_CREATE", D: raw})
msg := <-a.msgCh
if msg.ChatType != bot.ChatDirect {
t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDirect)
}
if msg.ChatID != "guild-1" {
t.Fatalf("chat id = %q, want guild-1", msg.ChatID)
}
}
func TestHandleDispatchC2CUsesUserOpenID(t *testing.T) {
a := &adapter{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
msgCh: make(chan bot.InboundMessage, 1),
}
raw, err := json.Marshal(map[string]any{
"id": "msg-1",
"content": "hello",
"author": map[string]string{
"user_openid": "openid-user",
"username": "user",
},
})
if err != nil {
t.Fatal(err)
}
a.handleDispatch(gatewayPayload{T: "C2C_MESSAGE_CREATE", D: raw})
msg := <-a.msgCh
if msg.UserID != "openid-user" {
t.Fatalf("user id = %q, want openid-user", msg.UserID)
}
if msg.ChatID != "openid-user" {
t.Fatalf("chat id = %q, want openid-user", msg.ChatID)
}
if msg.ChatType != bot.ChatDM {
t.Fatalf("chat type = %q, want %q", msg.ChatType, bot.ChatDM)
}
}
func TestQQSendURLDirectMessage(t *testing.T) {
got := qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDirect, ChatID: "guild-1"})
want := fmt.Sprintf("%s/v2/dms/%s/messages", qqBaseURL, "guild-1")
if got != want {
t.Fatalf("url = %q, want %q", got, want)
}
}
func TestQQSendURLUsesSandboxBase(t *testing.T) {
a := &adapter{cfg: config.QQBotConfig{Sandbox: true}}
got := a.qqSendURL(bot.OutboundMessage{ChatType: bot.ChatDM, ChatID: "user/open id"})
want := qqSandboxURL + "/v2/users/user%2Fopen%20id/messages"
if got != want {
t.Fatalf("url = %q, want %q", got, want)
}
}
func TestValidateGatewayURL(t *testing.T) {
for _, raw := range []string{
"wss://api.sgroup.qq.com/websocket",
"wss://sandbox.api.sgroup.qq.com/websocket",
"wss://gateway.qq.com/websocket",
} {
if _, err := validateGatewayURL(raw); err != nil {
t.Fatalf("valid gateway %q rejected: %v", raw, err)
}
}
for _, raw := range []string{
"http://api.sgroup.qq.com/websocket",
"wss://evil.example/websocket",
"wss://api.sgroup.qq.com/websocket?token=1",
"wss://user:pass@api.sgroup.qq.com/websocket",
} {
if _, err := validateGatewayURL(raw); err == nil {
t.Fatalf("invalid gateway %q accepted", raw)
}
}
}
func TestNormalizeQQMarkdownReply(t *testing.T) {
got := normalizeQQMarkdownReply("```markdown\n# Title\n\n**bold**\n```")
if got != "# Title\n\n**bold**" {
t.Fatalf("normalized markdown = %q", got)
}
normal := "Here is code:\n```go\nfmt.Println()\n```"
if got := normalizeQQMarkdownReply(normal); got != normal {
t.Fatalf("normal code block changed: %q", got)
}
}
func TestSplitQQMessageKeepsUTF8Budget(t *testing.T) {
chunks := splitQQMessage(strings.Repeat("中", 600), 1500)
if len(chunks) < 2 {
t.Fatalf("chunks = %d, want more than one", len(chunks))
}
for _, chunk := range chunks {
if len([]byte(chunk)) > 1500 {
t.Fatalf("chunk byte length = %d, want <= 1500", len([]byte(chunk)))
}
}
}
func TestFitUTF8SliceKeepsGraphemeCluster(t *testing.T) {
cluster := "👨‍👩‍👧‍👦"
got := fitUTF8Slice(cluster+"!", len([]byte(cluster)))
if got != cluster {
t.Fatalf("fitUTF8Slice split grapheme cluster: %q", got)
}
}
func TestCapQQPassiveReplyChunks(t *testing.T) {
chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes)
if len(chunks) <= qqMaxPassiveReplyChunks {
t.Fatalf("chunks = %d, want more than passive reply limit", len(chunks))
}
got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{
ChatType: bot.ChatDM,
ReplyToMsgID: "msg-id",
}, chunks)
if !truncated {
t.Fatal("capQQPassiveReplyChunks truncated = false, want true")
}
if len(got) != qqMaxPassiveReplyChunks {
t.Fatalf("capped chunks = %d, want %d", len(got), qqMaxPassiveReplyChunks)
}
for _, chunk := range got {
if len([]byte(chunk)) > qqMaxChunkBytes {
t.Fatalf("chunk byte length = %d, want <= %d", len([]byte(chunk)), qqMaxChunkBytes)
}
}
if !strings.Contains(got[len(got)-1], "Truncated") {
t.Fatalf("last chunk = %q, want truncation notice", got[len(got)-1])
}
}
func TestCapQQPassiveReplyChunksDoesNotCapNonPassiveReplies(t *testing.T) {
chunks := splitQQMessage(strings.Repeat("chunk-", 1800), qqMaxChunkBytes)
got, truncated := capQQPassiveReplyChunks(bot.OutboundMessage{
ChatType: bot.ChatDM,
}, chunks)
if truncated {
t.Fatal("capQQPassiveReplyChunks truncated non-passive reply")
}
if len(got) != len(chunks) {
t.Fatalf("chunks = %d, want %d", len(got), len(chunks))
}
got, truncated = capQQPassiveReplyChunks(bot.OutboundMessage{
ChatType: bot.ChatDirect,
ReplyToMsgID: "msg-id",
}, chunks)
if truncated {
t.Fatal("capQQPassiveReplyChunks truncated direct/guild reply")
}
if len(got) != len(chunks) {
t.Fatalf("chunks = %d, want %d", len(got), len(chunks))
}
}
func TestStartValidatesQQCredentialsBeforeRunning(t *testing.T) {
a := &adapter{}
if err := a.Start(context.Background()); err == nil {
t.Fatal("Start() error = nil, want missing app_id error")
}
if a.cancel != nil {
t.Fatal("Start() installed runtime cancel after validation failure")
}
}
func TestQQExpiresInSecondsAcceptsNumberAndString(t *testing.T) {
for _, tt := range []struct {
name string
value any
want int
}{
{name: "number", value: float64(3600), want: 3600},
{name: "string", value: "7200", want: 7200},
{name: "blank", value: "", want: 0},
{name: "missing", value: nil, want: 0},
} {
t.Run(tt.name, func(t *testing.T) {
got, err := qqExpiresInSeconds(tt.value)
if err != nil {
t.Fatalf("qqExpiresInSeconds() error = %v", err)
}
if got != tt.want {
t.Fatalf("qqExpiresInSeconds() = %d, want %d", got, tt.want)
}
})
}
}
func TestSendMessageMarkdownFallbackDisablesMarkdown(t *testing.T) {
t.Setenv("QQ_BOT_APP_SECRET", "secret")
origTransport := http.DefaultTransport
defer func() { http.DefaultTransport = origTransport }()
var bodies []map[string]any
sendCount := 0
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "bots.qq.com":
return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil
case "api.sgroup.qq.com":
if req.Header.Get("Authorization") != "QQBot token" {
t.Fatalf("authorization = %q, want QQBot token", req.Header.Get("Authorization"))
}
if req.Header.Get("X-Union-Appid") != "app-id" {
t.Fatalf("x-union-appid = %q, want app-id", req.Header.Get("X-Union-Appid"))
}
var body map[string]any
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
t.Fatal(err)
}
bodies = append(bodies, body)
sendCount++
if sendCount == 1 {
return jsonResponse(400, map[string]any{"message": "markdown rejected"}), nil
}
return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil
default:
t.Fatalf("unexpected request host: %s", req.URL.Host)
return nil, nil
}
})
a := &adapter{
cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
_, err := a.sendMessage(context.Background(), bot.OutboundMessage{
ChatType: bot.ChatDM,
ChatID: "openid-user",
Text: "**bold**",
ReplyToMsgID: "msg-id",
})
if err != nil {
t.Fatalf("send message: %v", err)
}
_, err = a.sendMessage(context.Background(), bot.OutboundMessage{
ChatType: bot.ChatDM,
ChatID: "openid-user",
Text: "**next**",
ReplyToMsgID: "msg-id-2",
})
if err != nil {
t.Fatalf("second send message: %v", err)
}
if len(bodies) != 3 {
t.Fatalf("sent bodies = %d, want 3", len(bodies))
}
if bodies[0]["msg_type"] != float64(2) || bodies[0]["markdown"] == nil || bodies[0]["msg_seq"] != float64(1) {
t.Fatalf("first body = %#v, want markdown msg_seq=1", bodies[0])
}
if bodies[1]["msg_type"] != float64(0) || bodies[1]["content"] != "**bold**" || bodies[1]["msg_seq"] != float64(2) {
t.Fatalf("fallback body = %#v, want plain msg_seq=2", bodies[1])
}
if bodies[2]["msg_type"] != float64(0) || bodies[2]["content"] != "**next**" || bodies[2]["msg_seq"] != float64(3) {
t.Fatalf("second body = %#v, want plain msg_seq=3", bodies[2])
}
}
func TestSendMessageReturnsAllChunkIDs(t *testing.T) {
t.Setenv("QQ_BOT_APP_SECRET", "secret")
origTransport := http.DefaultTransport
defer func() { http.DefaultTransport = origTransport }()
sendCount := 0
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "bots.qq.com":
return jsonResponse(200, map[string]any{"access_token": "token", "expires_in": 3600}), nil
case "api.sgroup.qq.com":
sendCount++
return jsonResponse(200, map[string]any{"id": fmt.Sprintf("sent-%d", sendCount)}), nil
default:
t.Fatalf("unexpected request host: %s", req.URL.Host)
return nil, nil
}
})
text := strings.Repeat("chunk-", 1800)
wantChunks := len(splitQQMessage(text, qqMaxChunkBytes))
a := &adapter{
cfg: config.QQBotConfig{AppID: "app-id", AppSecretEnv: "QQ_BOT_APP_SECRET"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
result, err := a.sendMessage(context.Background(), bot.OutboundMessage{
ChatType: bot.ChatDM,
ChatID: "openid-user",
Text: text,
})
if err != nil {
t.Fatalf("send message: %v", err)
}
if len(result.MessageIDs) != wantChunks {
t.Fatalf("message IDs = %v, want %d chunk IDs", result.MessageIDs, wantChunks)
}
if result.MessageID != fmt.Sprintf("sent-%d", wantChunks) {
t.Fatalf("compatibility message ID = %q, want last chunk", result.MessageID)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func jsonResponse(status int, v any) *http.Response {
data, _ := json.Marshal(v)
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(bytes.NewReader(data)),
}
}