- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和 聊天头部因此无内容可显示。沿用同一套 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>
102 lines
4.1 KiB
Go
102 lines
4.1 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestChatMediaObjectKeys(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
objectKey string
|
|
mediaType string
|
|
want []string
|
|
}{
|
|
{name: "chat image includes thumbnail", objectKey: "media/2026/09/1-2-im1.png", mediaType: "image", want: []string{"media/2026/09/1-2-im1.png", "media/2026/09/1-2-im1-thumb.jpg"}},
|
|
{name: "voice only has original", objectKey: "media/2026/09/1-2.m4a", mediaType: "voice", want: []string{"media/2026/09/1-2.m4a"}},
|
|
{name: "unrecognized image does not guess siblings", objectKey: "imported/photo.jpg", mediaType: "image", want: []string{"imported/photo.jpg"}},
|
|
{name: "empty key", objectKey: "", mediaType: "image", want: nil},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := chatMediaObjectKeys(test.objectKey, test.mediaType); !reflect.DeepEqual(got, test.want) {
|
|
t.Fatalf("chatMediaObjectKeys() = %#v, want %#v", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestChatMediaRetentionClamp(t *testing.T) {
|
|
for input, want := range map[int]int{-1: 90, 0: 90, 1: 1, 3650: 3650, 3651: 90} {
|
|
if got := clampChatMediaRetentionDays(input); got != want {
|
|
t.Fatalf("clampChatMediaRetentionDays(%d) = %d, want %d", input, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMissingMediaObjectDetection(t *testing.T) {
|
|
if !isMissingMediaObject(os.ErrNotExist) || !isMissingMediaObject(errors.New("NoSuchKey")) || isMissingMediaObject(errors.New("permission denied")) {
|
|
t.Fatal("missing-object detection did not classify storage errors correctly")
|
|
}
|
|
}
|
|
|
|
func TestChatMediaCleanupMySQL(t *testing.T) {
|
|
db := isolatedIMDatabase(t)
|
|
directory := t.TempDir()
|
|
if _, err := db.Exec(`UPDATE system_configs SET config_value=? WHERE config_key='storage.local.directory'`, directory); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const mainKey = "1-100-im1.png"
|
|
const thumbnailKey = "1-100-im1-thumb.jpg"
|
|
for _, name := range []string{mainKey, thumbnailKey} {
|
|
if err := os.WriteFile(filepath.Join(directory, name), []byte("media"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO im_conversations(id,conversation_type,last_seq) VALUES(100,1,1)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := db.Exec(`INSERT INTO media_assets(owner_user_id,media_type,storage_provider,object_key,public_url,mime_type,file_size,status) VALUES(1,'image','local',?,'https://media.example/chat.png','image/png',5,1)`, mainKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assetID, _ := result.LastInsertId()
|
|
result, err = db.Exec(`INSERT INTO im_messages(conversation_id,seq,sender_id,client_msg_id,message_type,body) VALUES(100,1,1,'cleanup-test-message-0001',2,'{"url":"https://media.example/chat.png"}')`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
messageID, _ := result.LastInsertId()
|
|
if _, err = db.Exec(`INSERT INTO im_message_media(message_id,media_asset_id,media_type,public_url,status) VALUES(?,?,'image','https://media.example/chat.png',1)`, messageID, assetID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
a := &App{db: db, config: Config{MediaDir: directory}, hub: NewHub()}
|
|
deleted, err := a.cleanupChatMedia(context.Background(), messageID, "test cleanup")
|
|
if err != nil || !deleted {
|
|
t.Fatalf("cleanupChatMedia() deleted=%v err=%v", deleted, err)
|
|
}
|
|
for _, name := range []string{mainKey, thumbnailKey} {
|
|
if _, statErr := os.Stat(filepath.Join(directory, name)); !os.IsNotExist(statErr) {
|
|
t.Fatalf("expected %s to be removed, stat error=%v", name, statErr)
|
|
}
|
|
}
|
|
var mediaStatus, assetStatus int
|
|
if err = db.QueryRow(`SELECT status FROM im_message_media WHERE message_id=?`, messageID).Scan(&mediaStatus); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = db.QueryRow(`SELECT status FROM media_assets WHERE id=?`, assetID).Scan(&assetStatus); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mediaStatus != 0 || assetStatus != 0 {
|
|
t.Fatalf("cleanup statuses media=%d asset=%d, want both 0", mediaStatus, assetStatus)
|
|
}
|
|
loaded := a.loadMessageContext(context.Background(), messageID)
|
|
if !loaded.MediaDeleted || !loaded.Recalled || loaded.Content != nil {
|
|
t.Fatalf("deleted media must be hidden from clients: %#v", loaded)
|
|
}
|
|
}
|