18c9354095
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
114 lines
4.5 KiB
Go
114 lines
4.5 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
|
"code.tczkiot.com/wlw/ai-agent/internal/services/storage"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/mlogclub/simple/sqls"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/schema"
|
|
)
|
|
|
|
func setupConversationVisionAssetTest(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
database, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{
|
|
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
if err := database.AutoMigrate(&models.Asset{}); err != nil {
|
|
t.Fatalf("migrate asset: %v", err)
|
|
}
|
|
sqls.SetDB(database)
|
|
config.SetCurrent(&config.Config{Storage: config.StorageConfig{
|
|
Default: enums.AssetProviderLocal, MaxUploadSizeMB: 20,
|
|
Local: config.LocalStorageConfig{Root: t.TempDir(), BaseURL: "/storage"},
|
|
}})
|
|
storage.SetHostStorage(nil)
|
|
t.Cleanup(func() { storage.SetHostStorage(nil) })
|
|
return database
|
|
}
|
|
|
|
func TestConversationVisionImagesAreInlineAndConversationScoped(t *testing.T) {
|
|
setupConversationVisionAssetTest(t)
|
|
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 520)...)
|
|
asset, err := AssetService.UploadConversationBytes(png, "images", "device.png", 11, nil)
|
|
if err != nil {
|
|
t.Fatalf("upload conversation image: %v", err)
|
|
}
|
|
if asset.ConversationID != 11 || asset.MimeType != "image/png" {
|
|
t.Fatalf("unexpected stored asset: %#v", asset)
|
|
}
|
|
payload := fmt.Sprintf(`{"asset_id":%q,"url":"https://attacker.invalid/ssrf.png","provider":"oss","storage_key":"other/customer.png"}`, asset.AssetID)
|
|
message := models.Message{
|
|
ID: 9, ConversationID: 11, SenderType: enums.IMSenderTypeCustomer,
|
|
MessageType: enums.IMMessageTypeImage, Payload: payload,
|
|
SendStatus: enums.IMMessageStatusSent,
|
|
}
|
|
images := AssetService.LoadConversationVisionImages(11, []models.Message{message}, 3)
|
|
if len(images) != 1 {
|
|
t.Fatalf("images = %#v, want one trusted image", images)
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(images[0].Base64Data)
|
|
if err != nil || string(decoded) != string(png) {
|
|
t.Fatalf("inline image mismatch: len=%d err=%v", len(decoded), err)
|
|
}
|
|
if images[0].MIMEType != "image/png" {
|
|
t.Fatalf("mime type = %q", images[0].MIMEType)
|
|
}
|
|
if got := AssetService.LoadConversationVisionImages(12, []models.Message{{
|
|
ID: 10, ConversationID: 12, SenderType: enums.IMSenderTypeCustomer,
|
|
MessageType: enums.IMMessageTypeImage, Payload: payload, SendStatus: enums.IMMessageStatusSent,
|
|
}}, 3); len(got) != 0 {
|
|
t.Fatalf("cross-conversation asset leaked into model input: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestConversationVisionImagesLoadsCompositeMessageInPayloadOrder(t *testing.T) {
|
|
setupConversationVisionAssetTest(t)
|
|
firstData := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 520)...)
|
|
secondData := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 640)...)
|
|
first, err := AssetService.UploadConversationBytes(firstData, "images", "front.png", 21, nil)
|
|
if err != nil {
|
|
t.Fatalf("upload first image: %v", err)
|
|
}
|
|
second, err := AssetService.UploadConversationBytes(secondData, "images", "label.png", 21, nil)
|
|
if err != nil {
|
|
t.Fatalf("upload second image: %v", err)
|
|
}
|
|
payload, err := buildIMMessageAssetBatchPayload([]*models.Asset{first, second})
|
|
if err != nil {
|
|
t.Fatalf("build batch payload: %v", err)
|
|
}
|
|
images := AssetService.LoadConversationVisionImages(21, []models.Message{{
|
|
ID: 22, ConversationID: 21, SenderType: enums.IMSenderTypeCustomer,
|
|
MessageType: enums.IMMessageTypeImage, Payload: payload, SendStatus: enums.IMMessageStatusSent,
|
|
}}, 6)
|
|
if len(images) != 2 || images[0].AssetID != first.AssetID || images[1].AssetID != second.AssetID {
|
|
t.Fatalf("composite image order mismatch: %#v", images)
|
|
}
|
|
}
|
|
|
|
func TestValidateConversationAssetRejectsCrossConversationAndFakeImage(t *testing.T) {
|
|
asset := &models.Asset{ConversationID: 7, Status: enums.AssetStatusSuccess, MimeType: "image/png"}
|
|
if err := validateConversationAsset(asset, 7, enums.IMMessageTypeImage); err != nil {
|
|
t.Fatalf("valid scoped image rejected: %v", err)
|
|
}
|
|
if err := validateConversationAsset(asset, 8, enums.IMMessageTypeImage); err == nil {
|
|
t.Fatal("cross-conversation asset must be rejected")
|
|
}
|
|
asset.MimeType = "text/html"
|
|
if err := validateConversationAsset(asset, 7, enums.IMMessageTypeImage); err == nil {
|
|
t.Fatal("non-image asset must not be sent as an image")
|
|
}
|
|
}
|