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, "
")`, "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) } } }