Files

418 lines
13 KiB
Go

package qq_obv11
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestAdapterNameAndConfig(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{
Mode: "client",
RemoteURL: "ws://onebot.example:6700",
AccessToken: "secret",
MaxProactivePerWindow: 3,
ProactiveWindow: 5 * time.Second,
})
if a.Name() != platformName {
t.Errorf("Name() = %q, want %q", a.Name(), platformName)
}
cfg := a.Config()
if cfg["mode"] != "client" {
t.Errorf("Config() mode = %v, want client", cfg["mode"])
}
if cfg["remoteURL"] != "ws://onebot.example:6700" {
t.Errorf("Config() remoteURL = %v", cfg["remoteURL"])
}
if cfg["accessToken"] != "secret" {
t.Errorf("Config() accessToken = %v", cfg["accessToken"])
}
if cfg["maxProactivePerWindow"] != 3 {
t.Errorf("Config() maxProactivePerWindow = %v, want 3", cfg["maxProactivePerWindow"])
}
if got := cfg["proactiveWindowSeconds"].(float64); got != 5 {
t.Errorf("Config() proactiveWindowSeconds = %v, want 5", got)
}
}
func TestAdapterDefaultModeIsServer(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
if a.Config()["mode"] != "server" {
t.Errorf("default mode = %v, want server", a.Config()["mode"])
}
}
func TestAdapterCapabilities(t *testing.T) {
t.Parallel()
caps := NewAdapter(Config{}).Capabilities().Normalize()
if caps.MaxMessageLength != defaultMaxMessageLength {
t.Errorf("MaxMessageLength = %d, want %d", caps.MaxMessageLength, defaultMaxMessageLength)
}
if caps.RecommendBurstMax != defaultRecommendBurstMax {
t.Errorf("RecommendBurstMax = %d, want %d", caps.RecommendBurstMax, defaultRecommendBurstMax)
}
if !caps.Markdown || !caps.Image || !caps.Voice || !caps.Proactive {
t.Errorf("expected markdown/image/voice/proactive on, got %+v", caps)
}
if caps.Reaction || caps.TypingHint {
t.Errorf("expected reaction/typing off, got %+v", caps)
}
}
func TestAdapterSetMessageHandlerAndDispatch(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
got := make(chan adapter.UnifiedMessage, 1)
a.SetMessageHandler(func(ctx context.Context, m adapter.UnifiedMessage) {
got <- m
})
want := adapter.UnifiedMessage{Platform: platformName, ChannelType: adapter.ChannelDirect, ContentType: adapter.ContentText, Content: "hi"}
a.dispatch(context.Background(), want)
select {
case m := <-got:
if m.Content != "hi" {
t.Errorf("handler got content = %q, want hi", m.Content)
}
case <-time.After(3 * time.Second):
t.Fatal("handler not invoked")
}
}
func TestAdapterHealthCheckDisconnected(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{Mode: "server", ListenAddr: "127.0.0.1:0"})
if a.IsConnected() {
t.Error("IsConnected() = true before Connect, want false")
}
if err := a.HealthCheck(context.Background()); !errors.Is(err, adapter.ErrNotConnected) {
t.Errorf("HealthCheck() error = %v, want ErrNotConnected", err)
}
}
func TestAdapterSendWhenDisconnected(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelDirect, ChannelID: "100", Content: "x"}},
})
if !errors.Is(err, adapter.ErrNotConnected) {
t.Errorf("SendMessage() error = %v, want ErrNotConnected", err)
}
}
func TestAdapterDisconnectIdempotent(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
ctx := context.Background()
if err := a.Disconnect(ctx); err != nil {
t.Fatalf("first Disconnect error: %v", err)
}
if err := a.Disconnect(ctx); err != nil {
t.Fatalf("second Disconnect error: %v", err)
}
}
// TestAdapterServerModeRoundTrip spins up the adapter as a server, connects a
// fake OneBot client, and verifies the message event is normalized and that a
// reply is emitted as a send_msg frame back to the client.
func TestAdapterServerModeRoundTrip(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{Mode: "server", ListenAddr: "127.0.0.1:0"})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
addr := a.Addr()
if addr == "" {
t.Fatal("Addr() empty after Connect")
}
if a.IsConnected() {
t.Error("server mode should not be connected before a client connects")
}
msgCh := make(chan adapter.UnifiedMessage, 1)
a.SetMessageHandler(func(ctx context.Context, m adapter.UnifiedMessage) { msgCh <- m })
client, err := dialWS(context.Background(), nil, "ws://"+addr+"/", "")
if err != nil {
t.Fatalf("client dial error: %v", err)
}
defer client.Close()
if !waitUntil(3*time.Second, a.IsConnected) {
t.Fatal("server never reported connected after client dialed")
}
evt := `{"post_type":"message","message_type":"group","time":1700000000,"self_id":100,"message_id":9,"user_id":42,"group_id":123,"group_name":"Team","message":[{"type":"text","data":{"text":"hello"}}],"sender":{"user_id":42,"nickname":"Bob","card":"Bobby","role":"member"}}`
if err := client.WriteMessage(opText, []byte(evt)); err != nil {
t.Fatalf("client write error: %v", err)
}
select {
case m := <-msgCh:
if m.Platform != platformName || m.ChannelType != adapter.ChannelGroup || m.GroupID != "123" {
t.Errorf("unexpected message: platform=%q type=%q group=%q", m.Platform, m.ChannelType, m.GroupID)
}
if m.GroupName != "Team" || m.Content != "hello" {
t.Errorf("group message = %+v, want Team/hello", m)
}
if m.SenderName != "Bobby" || m.Sender.Role != adapter.RoleMember {
t.Errorf("sender = %+v, want Bobby/member", m.Sender)
}
case <-time.After(3 * time.Second):
t.Fatal("server handler never received a message")
}
// Reply.
reply := adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{
{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "pong"},
}}
if err := a.SendMessage(context.Background(), reply); err != nil {
t.Fatalf("SendMessage error: %v", err)
}
seen := make(chan []byte, 1)
go func() {
_, payload, err := client.ReadMessage()
if err != nil {
seen <- nil
return
}
seen <- payload
}()
select {
case payload := <-seen:
if payload == nil {
t.Fatal("client never received a send_msg")
}
assertSendMsg(t, payload)
case <-time.After(3 * time.Second):
t.Fatal("client never received a send_msg frame")
}
}
// TestAdapterClientModeReceiveAndSend spins up a fake OneBot server that pushes
// a group event, then verifies the adapter (client) normalizes it and replies.
func TestAdapterClientModeReceiveAndSend(t *testing.T) {
t.Parallel()
gotCh := make(chan []byte, 4)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgradeWS(w, r)
if err != nil {
t.Errorf("server upgrade error: %v", err)
return
}
defer conn.Close()
evt := `{"post_type":"message","message_type":"group","time":1700000001,"self_id":100,"message_id":10,"user_id":42,"group_id":123,"message":[{"type":"text","data":{"text":"hi"}}],"sender":{"user_id":42,"nickname":"Bob","role":"member"}}`
_ = conn.WriteMessage(opText, []byte(evt))
for {
_, payload, err := conn.ReadMessage()
if err != nil {
return
}
gotCh <- payload
}
}))
defer srv.Close()
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
a := NewAdapter(Config{Mode: "client", RemoteURL: url})
msgCh := make(chan adapter.UnifiedMessage, 1)
a.SetMessageHandler(func(ctx context.Context, m adapter.UnifiedMessage) { msgCh <- m })
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
if !a.IsConnected() {
t.Error("client mode IsConnected() = false after Connect, want true")
}
if err := a.HealthCheck(context.Background()); err != nil {
t.Errorf("HealthCheck() error = %v, want nil", err)
}
select {
case m := <-msgCh:
if m.ChannelType != adapter.ChannelGroup || m.GroupID != "123" || m.Content != "hi" {
t.Errorf("received message = %+v, want group/123/hi", m)
}
case <-time.After(3 * time.Second):
t.Fatal("adapter never received the pushed event")
}
if err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "pong"}},
}); err != nil {
t.Fatalf("SendMessage error: %v", err)
}
select {
case payload := <-gotCh:
assertSendMsg(t, payload)
case <-time.After(3 * time.Second):
t.Fatal("fake OneBot server never received a send_msg")
}
}
// TestSendProactiveRateLimited verifies proactive sends are rejected after the
// configured per-window quota is exceeded.
func TestSendProactiveRateLimited(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgradeWS(w, r)
if err != nil {
return
}
defer conn.Close()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}))
defer srv.Close()
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
a := NewAdapter(Config{
Mode: "client",
RemoteURL: url,
MaxProactivePerWindow: 2,
ProactiveWindow: 10 * time.Second,
})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
msg := adapter.UnifiedMessage{ChannelType: adapter.ChannelDirect, ChannelID: "100", Content: "x"}
if err := a.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("first SendProactive error: %v", err)
}
if err := a.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("second SendProactive error: %v", err)
}
if err := a.SendProactive(context.Background(), msg); !errors.Is(err, ErrRateLimited) {
t.Errorf("third SendProactive error = %v, want ErrRateLimited", err)
}
}
// TestReadMessagesPullMode verifies the pull-based ReadMessages sink receives
// normalized messages when no push handler is installed.
func TestReadMessagesPullMode(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgradeWS(w, r)
if err != nil {
t.Errorf("server upgrade error: %v", err)
return
}
defer conn.Close()
evt := `{"post_type":"message","message_type":"private","time":1700000000,"self_id":100,"message_id":3,"user_id":200,"message":[{"type":"text","data":{"text":"hi"}}],"sender":{"user_id":200,"nickname":"Alice","role":""}}`
_ = conn.WriteMessage(opText, []byte(evt))
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}))
defer srv.Close()
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
a := NewAdapter(Config{Mode: "client", RemoteURL: url})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
emitCh := make(chan adapter.UnifiedMessage, 1)
go a.ReadMessages(ctx, func(m adapter.UnifiedMessage) { emitCh <- m })
// Wait until the sink is registered before connecting, so the pushed event
// is not dropped before ReadMessages installs its emit callback.
if !waitUntil(3*time.Second, func() bool {
a.emitMu.Lock()
defer a.emitMu.Unlock()
return a.emit != nil
}) {
t.Fatal("ReadMessages never registered its emit sink")
}
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
select {
case m := <-emitCh:
if m.ChannelType != adapter.ChannelDirect || m.ChannelID != "200" || m.Content != "hi" {
t.Errorf("pull-mode message = %+v, want direct/200/hi", m)
}
case <-time.After(3 * time.Second):
t.Fatal("ReadMessages never received the pushed event")
}
}
func assertSendMsg(t *testing.T, payload []byte) {
t.Helper()
var req struct {
Action string `json:"action"`
Params struct {
MessageType string `json:"message_type"`
UserID int64 `json:"user_id"`
GroupID int64 `json:"group_id"`
Message []map[string]any `json:"message"`
} `json:"params"`
}
if err := json.Unmarshal(payload, &req); err != nil {
t.Fatalf("send_msg unmarshal error: %v (payload=%s)", err, payload)
}
if req.Action != "send_msg" {
t.Errorf("action = %q, want send_msg", req.Action)
}
if req.Params.MessageType != "group" {
t.Errorf("message_type = %q, want group", req.Params.MessageType)
}
if req.Params.GroupID != 123 {
t.Errorf("group_id = %d, want 123", req.Params.GroupID)
}
if len(req.Params.Message) != 1 {
t.Fatalf("message segments len = %d, want 1", len(req.Params.Message))
}
first := req.Params.Message[0]
if first["type"] != "text" || first["data"].(map[string]any)["text"] != "pong" {
t.Errorf("first segment = %+v, want text pong", first)
}
}
func waitUntil(timeout time.Duration, cond func() bool) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(20 * time.Millisecond)
}
return false
}