Files
kefu/im/backend/internal/app/im_unread_integration_test.go
T
Your NameandClaude Opus 5 acd8933dbb 后端:资料距离、语音图片会员限制、免短信注册、AI 托管回复修复
- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和
  聊天头部因此无内容可显示。沿用同一套 haversine 与隐私开关。
- 语音/图片消息可限定会员发送,两个开关在管理端「运营配置」中修改
  (迁移 032)。校验放在 persistMessageContext,HTTP 与 WebSocket
  两条发送路径都覆盖;文本消息永不受限。
- 短信服务关闭时注册不再要求验证码:关掉之后没人能拿到验证码,继续
  要求就等于关闭注册通道。重置密码不做同样放宽,那里缺验证码等于
  凭手机号夺号。app/config 增加 smsVerification 供客户端决定表单形态。
- 修复 AI 托管账号之间不回复:原规则按「发送方是否托管账号」拦截,
  把真人操作测试号的正常对话也挡了。改为标记 worker 自己写入的回复,
  只对 AI 生成的消息跳过入队。
- ai.default_model_id 同时接受模型 ID 与名称,填名称时不再被 MySQL
  静默转成 0 而使配置失效。
- 聊天媒体留存管理与清理任务(迁移 033,两台线上均已应用)。

新增集成测试均针对真实 MySQL:会员限制、免短信注册、AI 入队规则、
默认模型解析、资料距离。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:00:08 +08:00

315 lines
11 KiB
Go

package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
"github.com/gorilla/websocket"
)
// Never run against IM or a remote database: create and clean up a unique local
// schema, apply the real migrations, and exercise the actual handlers/SQL.
func isolatedIMDatabase(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("IM_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set IM_TEST_MYSQL_DSN to enable isolated local IM integration tests")
}
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatal("invalid test DSN")
}
host, _, err := net.SplitHostPort(cfg.Addr)
if err != nil || cfg.Net != "tcp" || (host != "127.0.0.1" && host != "localhost" && host != "::1") || cfg.DBName != "" {
t.Fatal("IM tests require a loopback TCP DSN without a database name")
}
cfg.ParseTime, cfg.MultiStatements = true, true
admin, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { admin.Close() })
name := fmt.Sprintf("im_unread_test_%d", time.Now().UnixNano())
if !regexp.MustCompile(`^im_unread_test_[0-9]+$`).MatchString(name) {
t.Fatal("unsafe test schema")
}
if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := admin.Exec("DROP DATABASE `" + name + "`"); err != nil {
t.Errorf("test database cleanup: %v", err)
}
})
cfg.DBName = name
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
files, err := filepath.Glob(filepath.Join("..", "..", "migrations", "*.sql"))
if err != nil {
t.Fatal(err)
}
for _, file := range files {
body, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(string(body)); err != nil {
t.Fatalf("migration %s: %v", file, err)
}
}
for id := 1; id <= 3; id++ {
for _, query := range []string{
`INSERT INTO users(id,public_id,password_hash) VALUES(?,CONCAT('IMTEST',?),'!NO_LOGIN')`,
`INSERT INTO user_profiles(user_id,nickname) VALUES(?,CONCAT('IM测试',?))`,
`INSERT INTO user_privacy_settings(user_id) VALUES(?)`,
} {
args := []any{id}
if strings.Count(query, "?") == 2 {
args = append(args, id)
}
if _, err := db.Exec(query, args...); err != nil {
t.Fatal(err)
}
}
}
return db
}
func imTestRequest(user int64, method, path, body string) *http.Request {
r := httptest.NewRequest(method, path, strings.NewReader(body))
return r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{ID: user, Role: "user"}))
}
func imTestCall(t *testing.T, handler http.HandlerFunc, user int64, method, path, body string, wantStatus int) json.RawMessage {
t.Helper()
w := httptest.NewRecorder()
handler(w, imTestRequest(user, method, path, body))
if w.Code != wantStatus {
t.Fatalf("%s %s user %d: HTTP %d: %s", method, path, user, w.Code, w.Body.String())
}
var response struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response.Data
}
func TestIMUnreadMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
data := imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200)
var conversation struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(data, &conversation); err != nil {
t.Fatal(err)
}
id := conversation.ID
messagePath := fmt.Sprintf("/api/v1/im/conversations/%d/messages", id)
settingsPath := fmt.Sprintf("/api/v1/im/conversations/%d/settings", id)
unread := func(user int64, want int, empty bool) {
t.Helper()
var list struct {
Items []struct {
Unread int `json:"unread"`
LastMessageAt *string `json:"lastMessageAt"`
} `json:"items"`
}
if err := json.Unmarshal(imTestCall(t, a.conversations, user, "GET", "/api/v1/im/conversations", "", 200), &list); err != nil {
t.Fatal(err)
}
if len(list.Items) != 1 || list.Items[0].Unread != want {
t.Fatalf("user %d unread: %+v, want %d", user, list, want)
}
if empty {
if list.Items[0].LastMessageAt != nil {
t.Fatal("empty conversation time must be null")
}
return
}
if list.Items[0].LastMessageAt == nil {
t.Fatal("missing message timestamp")
}
if _, err := time.Parse(time.RFC3339Nano, *list.Items[0].LastMessageAt); err != nil {
t.Fatal("message time must be RFC3339, not sql.NullTime JSON")
}
}
send := func(user int64, key string) messageView {
t.Helper()
var message messageView
payload := fmt.Sprintf(`{"clientMsgId":%q,"type":1,"content":{"text":"hello"}}`, key)
if err := json.Unmarshal(imTestCall(t, a.sendMessageHTTP, user, "POST", messagePath, payload, 200), &message); err != nil {
t.Fatal(err)
}
return message
}
t.Run("empty conversation has no unread and null time", func(t *testing.T) { unread(1, 0, true); unread(2, 0, true) })
t.Run("sender zero recipient one", func(t *testing.T) { send(1, "a1"); unread(1, 0, false); unread(2, 1, false) })
t.Run("reply does not mark earlier incoming messages read", func(t *testing.T) {
first := send(2, "b1")
again := send(2, "b1")
if first.ID != again.ID {
t.Fatal("retry must be idempotent")
}
unread(1, 1, false)
unread(2, 1, false)
send(1, "a2")
unread(1, 1, false)
unread(2, 2, false)
})
t.Run("incremental history returns only the sequence gap", func(t *testing.T) {
var delta struct {
Items []messageView `json:"items"`
HasMore bool `json:"hasMore"`
NextAfterSeq int64 `json:"nextAfterSeq"`
}
body := imTestCall(t, a.messages, 2, "GET", messagePath+"?afterSeq=1&limit=1", "", 200)
if err := json.Unmarshal(body, &delta); err != nil {
t.Fatal(err)
}
if len(delta.Items) != 1 || delta.Items[0].Seq != 2 || !delta.HasMore || delta.NextAfterSeq != 2 {
t.Fatalf("unexpected incremental page: %+v", delta)
}
imTestCall(t, a.messages, 2, "GET", messagePath+"?beforeSeq=3&afterSeq=1", "", 400)
})
t.Run("older history cannot read newer incoming messages", func(t *testing.T) {
imTestCall(t, a.messages, 1, "GET", messagePath+"?beforeSeq=2&limit=1", "", 200)
unread(1, 1, false)
})
t.Run("latest history clears only the reader", func(t *testing.T) {
imTestCall(t, a.messages, 2, "GET", messagePath+"?limit=1", "", 200)
unread(1, 1, false)
unread(2, 0, false)
})
t.Run("read permissions and future sequence clamp", func(t *testing.T) {
imTestCall(t, a.conversationSettings, 3, "PUT", settingsPath, `{"readSeq":999}`, 403)
imTestCall(t, a.conversationSettings, 1, "PUT", settingsPath, `{"readSeq":-1}`, 400)
var state struct {
ReadSeq int64 `json:"readSeq"`
Unread int64 `json:"unread"`
}
if err := json.Unmarshal(imTestCall(t, a.conversationSettings, 1, "PUT", settingsPath, `{"readSeq":999}`, 200), &state); err != nil {
t.Fatal(err)
}
if state.ReadSeq != 3 || state.Unread != 0 {
t.Fatalf("authoritative read state: %+v", state)
}
var read int64
if err := db.QueryRow(`SELECT read_seq FROM im_conversation_members WHERE conversation_id=? AND user_id=1`, id).Scan(&read); err != nil || read != 3 {
t.Fatalf("read marker %d: %v", read, err)
}
unread(1, 0, false)
m := send(2, "b2")
unread(1, 1, false)
unread(2, 0, false)
imTestCall(t, a.recallMessage, 2, "POST", fmt.Sprintf("/api/v1/im/messages/%d/recall", m.ID), "", 200)
unread(1, 0, false)
})
t.Run("moderated and cleared messages are not unread", func(t *testing.T) {
m := send(1, "a3")
unread(2, 1, false)
if _, err := db.Exec(`UPDATE im_messages SET admin_removed_at=NOW(3) WHERE id=?`, m.ID); err != nil {
t.Fatal(err)
}
unread(2, 0, false)
if _, err := db.Exec(`UPDATE im_messages SET admin_removed_at=NULL WHERE id=?`, m.ID); err != nil {
t.Fatal(err)
}
unread(2, 1, false)
if _, err := db.Exec(`UPDATE im_conversation_members SET clear_seq=? WHERE conversation_id=? AND user_id=2`, m.Seq, id); err != nil {
t.Fatal(err)
}
unread(2, 0, false)
})
t.Run("websocket send echoes are persisted once and HTTP read broadcasts ack", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(a.websocket))
defer server.Close()
token, err := a.token(1, "user", "fixture", time.Minute)
if err != nil {
t.Fatal(err)
}
dialer := websocket.Dialer{Subprotocols: []string{"xingyu.jwt." + token}}
conn, _, err := dialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
readFrame := func() map[string]any {
t.Helper()
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
var frame map[string]any
if err := conn.ReadJSON(&frame); err != nil {
t.Fatal(err)
}
return frame
}
if readFrame()["command"] != "AUTH_ACK" {
t.Fatal("missing auth ack")
}
if err := conn.WriteJSON(map[string]any{"command": "SEND_MESSAGE", "conversationId": id, "clientMsgId": "ws-a4", "type": 1, "content": map[string]any{"text": "ws hello"}}); err != nil {
t.Fatal(err)
}
if readFrame()["command"] != "MESSAGE_PUSH" {
t.Fatal("missing sender echo")
}
unread(1, 0, false)
unread(2, 1, false)
imTestCall(t, a.messages, 1, "GET", messagePath, "", 200)
readAck := readFrame()
if readAck["command"] != "READ_ACK" {
t.Fatal("HTTP read must sync this user's other device")
}
if data, ok := readAck["data"].(map[string]any); !ok || data["unread"] != float64(0) {
t.Fatalf("HTTP read ack must include authoritative unread: %+v", readAck)
}
if err := conn.WriteJSON(map[string]any{"command": "READ", "conversationId": id, "readSeq": 999}); err != nil {
t.Fatal(err)
}
if readFrame()["command"] != "READ_ACK" {
t.Fatal("missing ws read ack")
}
incoming := send(2, "b3")
if readFrame()["command"] != "MESSAGE_PUSH" {
t.Fatal("missing incoming push")
}
unread(1, 1, false)
own := send(1, "a5")
if readFrame()["command"] != "MESSAGE_PUSH" {
t.Fatal("missing outgoing echo")
}
var readState struct {
ReadSeq int64 `json:"readSeq"`
Unread int64 `json:"unread"`
}
body := imTestCall(t, a.conversationSettings, 1, "PUT", settingsPath, fmt.Sprintf(`{"readSeq":%d}`, incoming.Seq), 200)
if err := json.Unmarshal(body, &readState); err != nil {
t.Fatal(err)
}
if readState.ReadSeq != own.Seq || readState.Unread != 0 {
t.Fatalf("own sequence must not keep a legacy badge alive: %+v, own seq %d", readState, own.Seq)
}
ack := readFrame()
if data, ok := ack["data"].(map[string]any); ack["command"] != "READ_ACK" || !ok || data["readSeq"] != float64(own.Seq) || data["unread"] != float64(0) {
t.Fatalf("legacy-compatible read ack: %+v", ack)
}
unread(1, 0, false)
})
}