Files
Cyrene_v2/plugins/channels/qq_obv11/probe_test.go
T
AskaEth a79fe9684e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): QQ 账号 get_status 主动探活(复用既有 action 通道 / 状态变化才记日志 / AccountStatus 与 WS 连通分开)
2026-10-05 12:10:34 +08:00

294 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package qq_obv11
// 本文件是 `get_status` 主动探活(probe.go)的端到端自证:用**本地假 OneBot 实现**
// (WS 反连 / HTTP API)真的收一次 get_status 请求、真的回一次响应,断言
// - 账号在线/离线被如实检测到(含 offline 判定),且 AccountStatus 可区分「未探到」;
// - 状态**只在变化时**记一条日志(用测试 handler 捕获渲染后的日志串来数行数);
// - 请求失败 / 超时不伪造状态,也不产生状态日志;
// - 后台探活循环在 Disconnect 后干净退出(不再发请求)。
//
// 绝不用真实 QQ:全部走本机 httptest 假服务端。
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// fakeStatusWS 是本地假的 OneBot WS 实现:只认 get_status 请求,并按当前 online/good
// 回一条带 echo 的标准响应信封。reply=false 时故意不回(模拟对端挂死 / 超时)。
type fakeStatusWS struct {
online atomic.Bool
good atomic.Bool
reply atomic.Bool
mu sync.Mutex
probes int
}
func (f *fakeStatusWS) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.probes
}
func (f *fakeStatusWS) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgradeWS(w, r)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
for {
_, payload, err := conn.ReadMessage()
if err != nil {
return
}
var req struct {
Action string `json:"action"`
Echo string `json:"echo"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.Action != actionGetStatus {
continue
}
f.mu.Lock()
f.probes++
f.mu.Unlock()
if !f.reply.Load() {
continue
}
body, _ := json.Marshal(map[string]any{
"status": "ok",
"retcode": 0,
"data": map[string]any{"online": f.online.Load(), "good": f.good.Load()},
"echo": req.Echo,
})
_ = conn.WriteMessage(opText, body)
}
})
}
// countLogLines 数渲染后日志里包含 sub 的行数(用于断言「只记一次」)。
func countLogLines(buf *bytes.Buffer, sub string) int {
n := 0
for _, line := range strings.Split(buf.String(), "\n") {
if strings.Contains(line, sub) {
n++
}
}
return n
}
// TestProbeDetectsAccountTransitionsOnceWS 覆盖 WS 端:真的发 get_status、真的收回执,
// 在线→离线被检测到,且每次状态变化**只记一条**日志(good 也带上)。
func TestProbeDetectsAccountTransitionsOnceWS(t *testing.T) {
t.Parallel()
buf, log := logCapture()
f := &fakeStatusWS{}
f.reply.Store(true)
f.good.Store(true)
srv := httptest.NewServer(f.handler())
defer srv.Close()
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
a := NewAdapter(Config{Mode: modeClient, RemoteURL: url, Logger: log})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
ctx := context.Background()
probe := func() {
t.Helper()
if err := a.probeStatus(ctx); err != nil {
t.Fatalf("probeStatus error: %v", err)
}
}
f.online.Store(true)
probe() // 首次确立:在线 → 记一条
probe() // 状态未变 → 不记
if online, known := a.AccountStatus(); !known || !online {
t.Fatalf("AccountStatus() = (%v,%v), want (true,true) after online probe", online, known)
}
f.online.Store(false)
probe() // 在线→离线 → 记一条
probe() // 状态未变 → 不记
if online, known := a.AccountStatus(); !known || online {
t.Fatalf("AccountStatus() = (%v,%v), want (false,true) after offline probe", online, known)
}
if n := countLogLines(buf, "onebot account online"); n != 1 {
t.Errorf("「account online」日志行数 = %d, want 1\n%s", n, buf.String())
}
if n := countLogLines(buf, "onebot account offline"); n != 1 {
t.Errorf("「account offline」日志行数 = %d, want 1\n%s", n, buf.String())
}
if !strings.Contains(buf.String(), "good=true") {
t.Errorf("状态日志缺少 OneBot 返回的 good\n%s", buf.String())
}
if !strings.Contains(buf.String(), "source=plugins/channels/qq_obv11") {
t.Errorf("状态日志缺少 source\n%s", buf.String())
}
}
// TestProbeOverHTTPTransport 覆盖 HTTP 端:get_status 走既有 obHTTPClient,同样能检测
// 离线→在线;证明两条传输共用同一套探活逻辑。
func TestProbeOverHTTPTransport(t *testing.T) {
t.Parallel()
buf, log := logCapture()
var online atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"retcode": 0,
"data": map[string]any{"online": online.Load(), "good": true},
})
}))
defer srv.Close()
a := NewAdapter(Config{
Transport: transportHTTP,
Mode: modeClient,
APIBaseURL: srv.URL,
Logger: log,
})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
ctx := context.Background()
online.Store(false)
if err := a.probeStatus(ctx); err != nil {
t.Fatalf("probeStatus error: %v", err)
}
if got, known := a.AccountStatus(); !known || got {
t.Fatalf("AccountStatus() = (%v,%v), want (false,true)", got, known)
}
online.Store(true)
if err := a.probeStatus(ctx); err != nil {
t.Fatalf("probeStatus error: %v", err)
}
if got, known := a.AccountStatus(); !known || !got {
t.Fatalf("AccountStatus() = (%v,%v), want (true,true)", got, known)
}
if n := countLogLines(buf, "onebot account offline"); n != 1 {
t.Errorf("「account offline」日志行数 = %d, want 1\n%s", n, buf.String())
}
if n := countLogLines(buf, "onebot account online"); n != 1 {
t.Errorf("「account online」日志行数 = %d, want 1\n%s", n, buf.String())
}
}
// TestProbeTimeoutWithoutReply 覆盖失败路径:对端收下请求却不回执 ⇒ 探活超时返回错误,
// 账号状态保持「未探到」,绝不伪造在线/离线,也不产生状态日志。
func TestProbeTimeoutWithoutReply(t *testing.T) {
t.Parallel()
buf, log := logCapture()
f := &fakeStatusWS{} // reply=false:只计数不回执
f.reply.Store(false)
srv := httptest.NewServer(f.handler())
defer srv.Close()
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
a := NewAdapter(Config{Mode: modeClient, RemoteURL: url, Logger: log})
a.probeTimeout = 150 * time.Millisecond
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
err := a.probeStatus(context.Background())
if err == nil {
t.Fatal("probeStatus() = nil, want timeout error")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("probeStatus error = %v, want context.DeadlineExceeded", err)
}
if _, known := a.AccountStatus(); known {
t.Error("AccountStatus known = true after failed probe, want false (未探到)")
}
if n := countLogLines(buf, "onebot account"); n != 0 {
t.Errorf("失败探活不应产生状态日志,实得 %d 行\n%s", n, buf.String())
}
if f.count() == 0 {
t.Error("假服务端从未收到 get_status 请求,探活根本没走既有通道")
}
}
// TestProbeHTTPRetcodeFailure 覆盖 HTTP 端失败判定:OneBot 回 retcode!=0 时探活报错,
// 状态不更新。
func TestProbeHTTPRetcodeFailure(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": "failed", "retcode": 100, "msg": "nope"})
}))
defer srv.Close()
a := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient, APIBaseURL: srv.URL})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
if err := a.probeStatus(context.Background()); err == nil {
t.Fatal("probeStatus() = nil, want the OneBot retcode failure surfaced")
}
if _, known := a.AccountStatus(); known {
t.Error("AccountStatus known = true after failed probe, want false")
}
}
// TestProbeLoopStopsOnDisconnect 覆盖后台探活循环:按 probeEvery 周期真的发请求,且
// Disconnect 后干净退出(不再有新请求),不泄漏 goroutine。
func TestProbeLoopStopsOnDisconnect(t *testing.T) {
t.Parallel()
_, log := logCapture()
f := &fakeStatusWS{}
f.reply.Store(true)
f.online.Store(true)
srv := httptest.NewServer(f.handler())
defer srv.Close()
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
a := NewAdapter(Config{Mode: modeClient, RemoteURL: url, Logger: log})
a.probeEvery = 5 * time.Millisecond
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
if !waitUntil(3*time.Second, func() bool { return f.count() >= 2 }) {
t.Fatalf("后台探活循环未发出请求(probes=%d)", f.count())
}
if err := a.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect error: %v", err)
}
after := f.count()
time.Sleep(80 * time.Millisecond) // ~16 个 probeEvery
if final := f.count(); final != after {
t.Errorf("Disconnect 后探活仍在继续:%d → %d", after, final)
}
}