Files
ai-agent/internal/web/supportchat/handler_test.go
T
t 18c9354095 refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
2026-08-28 22:23:13 +08:00

226 lines
7.7 KiB
Go

package supportchat
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestSupportChatRoutesServeEmbeddedPage(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
RegisterRoutes(router)
tests := []struct {
path string
contentType string
contains string
}{
{path: "/support/chat", contentType: "text/html", contains: "在线客服"},
{path: "/support/chat/", contentType: "text/html", contains: "message-scroller"},
{path: "/support/chat/chat.css", contentType: "text/css", contains: ".support-app"},
{path: "/support/chat/chat.js", contentType: "application/javascript", contains: "channel_id"},
{path: "/support/demo", contentType: "text/html", contains: "客服渠道测试页"},
{path: "/support/demo/", contentType: "text/html", contains: "widget-launcher"},
{path: "/support/demo/demo.css", contentType: "text/css", contains: ".demo-app"},
{path: "/support/demo/demo.js", contentType: "application/javascript", contains: "external_id"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", response.Code, http.StatusOK)
}
if contentType := response.Header().Get("Content-Type"); !strings.Contains(contentType, tt.contentType) {
t.Fatalf("unexpected content type: %q", contentType)
}
if !strings.Contains(response.Body.String(), tt.contains) {
t.Fatalf("response does not contain %q", tt.contains)
}
if cacheControl := response.Header().Get("Cache-Control"); cacheControl != "no-cache" {
t.Fatalf("unexpected cache control: %q", cacheControl)
}
})
}
}
func TestSupportDemoUsesSnakeCaseIdentityAndEmbedsChat(t *testing.T) {
script, err := assets.ReadFile("assets/demo.js")
if err != nil {
t.Fatalf("read support demo script: %v", err)
}
source := string(script)
for _, marker := range []string{"channel_id", "external_id", "external_name", "/support/chat/"} {
if !strings.Contains(source, marker) {
t.Fatalf("support demo script does not contain %q", marker)
}
}
for _, forbidden := range []string{"externalId", "externalName"} {
if strings.Contains(source, forbidden) {
t.Fatalf("support demo script must not contain camelCase identity key %q", forbidden)
}
}
}
func TestSupportChatUsesRealtimeUIWithoutMessagePolling(t *testing.T) {
index, err := assets.ReadFile("assets/index.html")
if err != nil {
t.Fatalf("read support chat index: %v", err)
}
for _, marker := range []string{
`maximum-scale=1, user-scalable=no`,
`id="connection-badge"`,
`aria-label="Messages"`,
`id="close-overlay"`,
`id="pending-uploads"`,
`id="image-input" type="file" accept="image/*" multiple`,
`id="queue-status"`,
`id="queue-position"`,
`id="queue-eta"`,
`enterkeyhint="enter"`,
`输入消息,换行键换行`,
`点击按钮发送`,
} {
if !strings.Contains(string(index), marker) {
t.Fatalf("support chat index does not contain %q", marker)
}
}
script, err := assets.ReadFile("assets/chat.js")
if err != nil {
t.Fatalf("read support chat script: %v", err)
}
source := string(script)
if !strings.Contains(source, "new WebSocket(websocketUrl())") {
t.Fatal("support chat must connect through the realtime websocket")
}
if strings.Contains(source, "}, 2500)") {
t.Fatal("support chat must not poll conversations and messages every 2.5 seconds")
}
for _, marker := range []string{
"function updateQueueStatus",
"function refreshConversationQueue",
`container.append(sanitizeHtml(toSafeHtml(message.content || "")))`,
`const allowedTags = new Set([`,
`.split(/\n{2,}/)`,
`paragraph.replace(/\n/g, "<br>")`,
"async function openQuickDialog",
"await loadQuickActions()",
"你可以继续留言",
"等待期间可以继续留言",
`String(event.type || "").startsWith("conversation.")`,
} {
if !strings.Contains(source, marker) {
t.Fatalf("support chat queue flow does not contain %q", marker)
}
}
if strings.Contains(source, `event.key === "Enter" && !event.shiftKey`) {
t.Fatal("support chat must only send through the send button; Enter should insert a newline")
}
if strings.Contains(source, `container.textContent = message.content || ""`) {
t.Fatal("plain text messages must render through the safe HTML newline renderer")
}
}
func TestSupportChatStagesUploadsUntilExplicitSend(t *testing.T) {
script, err := assets.ReadFile("assets/chat.js")
if err != nil {
t.Fatalf("read support chat script: %v", err)
}
source := string(script)
for _, marker := range []string{
"state.pendingUploads",
"function stageFiles",
"function sendComposer",
`dom.send.addEventListener("click", sendComposer)`,
`stageFiles(Array.from(dom.imageInput.files || []), "image")`,
`await createMessage("image", content ? toSafeHtml(content) : "", JSON.stringify({ assets }))`,
`const assets = Array.isArray(payload.assets) && payload.assets.length ? payload.assets : [payload]`,
"function settleInitialScroll",
"state.initialScrollSettling",
} {
if !strings.Contains(source, marker) {
t.Fatalf("support chat staged upload flow does not contain %q", marker)
}
}
for _, forbidden := range []string{
`uploadFile(dom.imageInput.files?.[0], "image")`,
`uploadFile(dom.fileInput.files?.[0], "attachment")`,
} {
if strings.Contains(source, forbidden) {
t.Fatalf("file selection must not send immediately: found %q", forbidden)
}
}
}
func TestSupportChatRequiresCurrentVerifiedNumberSession(t *testing.T) {
index, err := assets.ReadFile("assets/index.html")
if err != nil {
t.Fatalf("read support chat index: %v", err)
}
for _, marker := range []string{
`id="access-overlay"`,
`id="access-password-tab"`,
`id="access-sms-tab"`,
`id="access-send-code"`,
`id="access-submit"`,
} {
if !strings.Contains(string(index), marker) {
t.Fatalf("support chat verification dialog does not contain %q", marker)
}
}
script, err := assets.ReadFile("assets/chat.js")
if err != nil {
t.Fatalf("read support chat script: %v", err)
}
source := string(script)
for _, marker := range []string{
`new URLSearchParams(window.location.hash.replace(/^#/, ""))`,
`h5AccessRequest("/access/send-code"`,
`h5AccessRequest("/access/authorize"`,
`h5AccessRequest("/access/chat-entry"`,
`"X-H5-Access-Token": accessToken`,
`headers.set("X-H5-Chat-Session", "required")`,
`headers.set("X-H5-Chat-Binding", state.chatBinding)`,
`query.set("h5_chat_session", "required")`,
`query.set("h5_chat_binding", state.chatBinding)`,
`credentials: "include"`,
`window.sessionStorage.setItem(key, value)`,
`destination.searchParams.set("access_ticket", ticket)`,
} {
if !strings.Contains(source, marker) {
t.Fatalf("support chat verified-session flow does not contain %q", marker)
}
}
for _, forbidden := range []string{
`localStorage.setItem("h5_access_token"`,
`sessionStorage.setItem("h5_access_token"`,
} {
if strings.Contains(source, forbidden) {
t.Fatalf("support chat must not expose reusable number credentials: found %q", forbidden)
}
}
wsStart := strings.Index(source, "function websocketUrl()")
if wsStart < 0 {
t.Fatal("support chat websocket builder not found")
}
wsEnd := strings.Index(source[wsStart:], "function connectSocket()")
if wsEnd < 0 {
t.Fatal("support chat websocket builder end not found")
}
websocketSource := source[wsStart : wsStart+wsEnd]
for _, forbidden := range []string{`query.set("card_no"`, `query.set("device_no"`} {
if strings.Contains(websocketSource, forbidden) {
t.Fatalf("websocket URL must not expose raw target number: found %q", forbidden)
}
}
}