226 lines
7.7 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|