HH-469: close WebSocket fanout contract gaps (#109)

* HH-469: close websocket fanout contract gaps

* fix: unify message sender contracts

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-23 00:22:01 +08:00
committed by GitHub
co-authored by rogee
parent f91dc6ca1a
commit ef8931099c
14 changed files with 377 additions and 114 deletions
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
@@ -23,9 +24,12 @@ import (
type fanoutSeed struct {
AccountID uint `json:"account_id"`
ConversationID uint `json:"conversation_id"`
ConversationDisplayID uint `json:"conversation_display_id"`
CaptainAssistantID uint `json:"captain_assistant_id"`
AdminEmail string `json:"admin_email"`
AdminPassword string `json:"admin_password"`
WidgetToken string `json:"widget_token"`
}
type testProcess struct {
@@ -49,9 +53,12 @@ func TestWebSocketMultiInstanceFanout(t *testing.T) {
require.NotEmpty(t, redisDSN, "GOCHAT_REDIS_DSN is required")
root := moduleRoot(t)
stamp := strconv.FormatInt(time.Now().UnixNano(), 10)
evidenceDir := os.Getenv("GOCHAT_WS_E2E_EVIDENCE_DIR")
if evidenceDir == "" {
evidenceDir = t.TempDir()
} else {
evidenceDir = filepath.Join(evidenceDir, stamp)
}
require.NoError(t, os.MkdirAll(evidenceDir, 0o755))
@@ -73,7 +80,6 @@ func TestWebSocketMultiInstanceFanout(t *testing.T) {
t.Fatalf("build GoChat: %v\n%s", err, output)
}
stamp := strconv.FormatInt(time.Now().UnixNano(), 10)
baseEnv := map[string]string{
"GOCHAT_ENV": "development",
"GOCHAT_DATABASE_DSN": databaseDSN,
@@ -102,18 +108,10 @@ func TestWebSocketMultiInstanceFanout(t *testing.T) {
serverEnv := cloneMap(baseEnv)
serverEnv["GOCHAT_DATABASE_RUN_MIGRATIONS"] = "false"
serverEnv["GOCHAT_SERVER_HOST"] = "127.0.0.1"
portA, portB := freePort(t), freePort(t)
for portB == portA {
portB = freePort(t)
}
baseURLA := fmt.Sprintf("http://127.0.0.1:%d", portA)
baseURLB := fmt.Sprintf("http://127.0.0.1:%d", portB)
instanceA := startTestProcess(t, root, binary, portA, serverEnv, filepath.Join(evidenceDir, "instance-a.log"))
instanceB := startTestProcess(t, root, binary, portB, serverEnv, filepath.Join(evidenceDir, "instance-b.log"))
t.Cleanup(instanceB.stop)
instanceA, baseURLA := startHealthyTestProcess(t, root, binary, serverEnv, filepath.Join(evidenceDir, "instance-a.log"))
t.Cleanup(instanceA.stop)
waitForHealth(t, baseURLA)
waitForHealth(t, baseURLB)
instanceB, baseURLB := startHealthyTestProcess(t, root, binary, serverEnv, filepath.Join(evidenceDir, "instance-b.log"))
t.Cleanup(instanceB.stop)
authHeaders := signIn(t, baseURLA, seedData.AdminEmail, seedData.AdminPassword)
connA, identifier := connectAccountCable(t, baseURLA, issueWSTicket(t, baseURLA, authHeaders), seedData.AccountID)
@@ -122,18 +120,46 @@ func TestWebSocketMultiInstanceFanout(t *testing.T) {
defer connB.Close()
require.JSONEq(t, identifier, identifierB)
content := "multi-instance fanout " + stamp
created := createMessage(t, baseURLA, authHeaders, seedData, content)
frameA := readMessageCreated(t, connA, content)
frameB := readMessageCreated(t, connB, content)
agentBotID := startAITakeover(t, baseURLA, authHeaders, seedData)
senderCases := []struct {
name string
senderType string
senderObjectType string
senderID uint
widget bool
attachment bool
}{
{name: "agent_bot", senderType: "AgentBot", senderObjectType: "agent_bot", senderID: agentBotID},
{name: "contact", senderType: "Contact", senderObjectType: "contact", widget: true},
{name: "captain_assistant", senderType: "Captain::Assistant", senderObjectType: "captain_assistant", senderID: seedData.CaptainAssistantID},
{name: "user", senderType: "User", senderObjectType: "user", attachment: true},
}
frames := make(map[string]any, len(senderCases))
for _, senderCase := range senderCases {
content := "multi-instance " + senderCase.name + " " + stamp
var created map[string]any
if senderCase.widget {
createWidgetMessage(t, baseURLA, seedData, content)
} else {
created = createDashboardMessage(t, baseURLA, authHeaders, seedData, content, senderCase.senderType, senderCase.senderID, senderCase.attachment)
}
frameA := readMessageCreated(t, connA, content)
frameB := readMessageCreated(t, connB, content)
if created == nil {
created = fetchHTTPMessage(t, baseURLA, authHeaders, seedData, content)
}
require.Equal(t, frameA, frameB, "both processes must deliver the complete identical frame")
assertFanoutContract(t, frameA, identifier, created, seedData, content)
require.Equal(t, frameA, frameB, "%s sender must fan out identically", senderCase.name)
assertFanoutContract(t, frameA, identifier, created, seedData, content, senderCase.senderType, senderCase.senderObjectType, !senderCase.widget, senderCase.attachment)
assertFanoutContract(t, frameB, identifier, created, seedData, content, senderCase.senderType, senderCase.senderObjectType, !senderCase.widget, senderCase.attachment)
frames[senderCase.name+"_a"] = frameA
frames[senderCase.name+"_b"] = frameB
}
report["status"] = "passed"
report["instance_a"] = baseURLA
report["instance_b"] = baseURLB
report["frame"] = frameA
report["frames"] = frames
}
func moduleRoot(t *testing.T) string {
@@ -190,7 +216,9 @@ func freePort(t *testing.T) int {
func startTestProcess(t *testing.T, root, binary string, port int, baseEnv map[string]string, logPath string) *testProcess {
t.Helper()
logFile, err := os.Create(logPath)
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
require.NoError(t, err)
_, err = fmt.Fprintf(logFile, "\n=== start attempt on port %d ===\n", port)
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, binary, "serve")
@@ -205,6 +233,21 @@ func startTestProcess(t *testing.T, root, binary string, port int, baseEnv map[s
return &testProcess{cancel: cancel, cmd: cmd, done: done, log: logFile}
}
func startHealthyTestProcess(t *testing.T, root, binary string, baseEnv map[string]string, logPath string) (*testProcess, string) {
t.Helper()
for attempt := 1; attempt <= 5; attempt++ {
port := freePort(t)
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
process := startTestProcess(t, root, binary, port, baseEnv, logPath)
if waitForHealth(baseURL, process, 45*time.Second) {
return process, baseURL
}
process.stop()
}
t.Fatalf("GoChat failed to claim a free port after 5 attempts; see %s", logPath)
return nil, ""
}
func (process *testProcess) stop() {
process.cancel()
select {
@@ -216,21 +259,26 @@ func (process *testProcess) stop() {
_ = process.log.Close()
}
func waitForHealth(t *testing.T, baseURL string) {
t.Helper()
func waitForHealth(baseURL string, process *testProcess, timeout time.Duration) bool {
client := &http.Client{Timeout: time.Second}
deadline := time.Now().Add(45 * time.Second)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
response, err := client.Get(baseURL + "/health")
if err == nil {
response.Body.Close()
if response.StatusCode == http.StatusOK {
return
return true
}
}
select {
case processErr := <-process.done:
process.done <- processErr
return false
default:
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("GoChat did not become healthy: %s", baseURL)
return false
}
func signIn(t *testing.T, baseURL, email, password string) http.Header {
@@ -308,26 +356,114 @@ func responseStatus(response *http.Response) any {
return response.StatusCode
}
func createMessage(t *testing.T, baseURL string, authHeaders http.Header, seed fanoutSeed, content string) map[string]any {
func startAITakeover(t *testing.T, baseURL string, authHeaders http.Header, seed fanoutSeed) uint {
t.Helper()
body, err := json.Marshal(map[string]any{"content": content, "message_type": "outgoing", "private": false})
endpoint := fmt.Sprintf("%s/api/v1/accounts/%d/conversations/%d/ai_takeover", baseURL, seed.AccountID, seed.ConversationDisplayID)
request, err := http.NewRequest(http.MethodPost, endpoint, nil)
require.NoError(t, err)
setAuthHeaders(request, authHeaders)
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode, "start AI takeover response: %s", responseBody)
var conversation map[string]any
require.NoError(t, json.Unmarshal(responseBody, &conversation))
assignee := conversation["meta"].(map[string]any)["assignee"].(map[string]any)
return uint(assignee["id"].(float64))
}
func createDashboardMessage(t *testing.T, baseURL string, authHeaders http.Header, seed fanoutSeed, content, senderType string, senderID uint, attachment bool) map[string]any {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for key, value := range map[string]string{
"content": content,
"message_type": "outgoing",
"private": "false",
"source_id": "ws-e2e-source",
"echo_id": "ws-e2e-echo",
"content_attributes": `{"e2e":{"enabled":true,"levels":[1,"two",{"deep":null}]}}`,
"additional_attributes": `{"e2e":{"score":2.5,"tags":["fanout",7]}}`,
"external_source_ids": `{"e2e":{"source":"fanout","ids":["a",2]}}`,
} {
require.NoError(t, writer.WriteField(key, value))
}
if senderType != "User" {
require.NoError(t, writer.WriteField("sender_type", senderType))
require.NoError(t, writer.WriteField("sender_id", strconv.FormatUint(uint64(senderID), 10)))
}
if attachment {
part, err := writer.CreateFormFile("attachments[]", "fanout.txt")
require.NoError(t, err)
_, err = part.Write([]byte("fanout attachment"))
require.NoError(t, err)
}
require.NoError(t, writer.Close())
endpoint := fmt.Sprintf("%s/api/v1/accounts/%d/conversations/%d/messages", baseURL, seed.AccountID, seed.ConversationDisplayID)
request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
request, err := http.NewRequest(http.MethodPost, endpoint, &body)
require.NoError(t, err)
request.Header.Set("Content-Type", writer.FormDataContentType())
setAuthHeaders(request, authHeaders)
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode, "create message response: %s", responseBody)
var created map[string]any
require.NoError(t, json.Unmarshal(responseBody, &created))
return created
}
func createWidgetMessage(t *testing.T, baseURL string, seed fanoutSeed, content string) {
t.Helper()
body, err := json.Marshal(map[string]any{"content": content, "conversation_id": seed.ConversationID})
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, baseURL+"/widget/messages", bytes.NewReader(body))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Widget-Token", seed.WidgetToken)
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode, "create widget message response: %s", responseBody)
}
func fetchHTTPMessage(t *testing.T, baseURL string, authHeaders http.Header, seed fanoutSeed, content string) map[string]any {
t.Helper()
endpoint := fmt.Sprintf("%s/api/v1/accounts/%d/conversations/%d/messages", baseURL, seed.AccountID, seed.ConversationDisplayID)
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
require.NoError(t, err)
setAuthHeaders(request, authHeaders)
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode, "list messages response: %s", responseBody)
var result struct {
Payload []map[string]any `json:"payload"`
}
require.NoError(t, json.Unmarshal(responseBody, &result))
for _, message := range result.Payload {
if message["content"] == content {
return message
}
}
t.Fatalf("HTTP message response did not contain %q", content)
return nil
}
func setAuthHeaders(request *http.Request, authHeaders http.Header) {
for _, name := range []string{"access-token", "client", "uid", "token-type"} {
if value := authHeaders.Get(name); value != "" {
request.Header.Set(name, value)
}
}
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
defer response.Body.Close()
require.Equal(t, http.StatusOK, response.StatusCode)
var created map[string]any
require.NoError(t, json.NewDecoder(response.Body).Decode(&created))
return created
}
func readMessageCreated(t *testing.T, conn *websocket.Conn, content string) map[string]any {
@@ -346,7 +482,7 @@ func readMessageCreated(t *testing.T, conn *websocket.Conn, content string) map[
}
}
func assertFanoutContract(t *testing.T, frame map[string]any, identifier string, created map[string]any, seed fanoutSeed, content string) {
func assertFanoutContract(t *testing.T, frame map[string]any, identifier string, created map[string]any, seed fanoutSeed, content, senderType, senderObjectType string, structured, attachment bool) {
t.Helper()
require.ElementsMatch(t, []string{"identifier", "message"}, mapKeys(frame))
require.JSONEq(t, identifier, frame["identifier"].(string))
@@ -355,18 +491,72 @@ func assertFanoutContract(t *testing.T, frame map[string]any, identifier string,
require.Equal(t, "message.created", message["event"])
require.Equal(t, float64(seed.AccountID), message["account_id"])
payload := message["data"].(map[string]any)
for _, key := range []string{
"id", "account_id", "inbox_id", "conversation_id", "content", "message_type",
"content_type", "status", "private", "external", "sender_type", "created_at", "conversation",
} {
require.Contains(t, payload, key)
}
for _, key := range []string{"id", "account_id", "inbox_id", "conversation_id", "content", "message_type", "content_type", "status", "private", "external"} {
require.Equal(t, created[key], payload[key], "payload field %s must match the HTTP contract", key)
}
assertWSTransportFields(t, payload, created, senderType)
require.Equal(t, normalizedHTTPMessage(t, created), normalizedWSMessage(t, payload), "normalized HTTP and WebSocket message contracts must match completely")
require.Equal(t, content, payload["content"])
require.Equal(t, "User", payload["sender_type"])
require.Contains(t, payload["conversation"].(map[string]any), "last_activity_at")
assertNestedFrontendFields(t, payload, senderObjectType, structured, attachment)
}
func assertWSTransportFields(t *testing.T, payload, created map[string]any, senderType string) {
t.Helper()
require.Equal(t, senderType, payload["sender_type"])
httpSender := created["sender"].(map[string]any)
require.Equal(t, httpSender["id"], payload["sender_id"])
require.Nil(t, payload["deleted_at"])
_, err := time.Parse(time.RFC3339Nano, payload["updated_at"].(string))
require.NoError(t, err)
conversation := payload["conversation"].(map[string]any)
require.ElementsMatch(t, []string{"assignee_id", "contact_inbox", "last_activity_at"}, mapKeys(conversation))
require.IsType(t, float64(0), conversation["last_activity_at"])
contactInbox := conversation["contact_inbox"].(map[string]any)
require.IsType(t, "", contactInbox["source_id"])
}
func normalizedHTTPMessage(t *testing.T, message map[string]any) map[string]any {
t.Helper()
return cloneJSONMap(t, message)
}
func normalizedWSMessage(t *testing.T, message map[string]any) map[string]any {
t.Helper()
normalized := cloneJSONMap(t, message)
for _, key := range []string{"conversation", "deleted_at", "sender_id", "sender_type", "updated_at"} {
delete(normalized, key)
}
return normalized
}
func cloneJSONMap(t *testing.T, value map[string]any) map[string]any {
t.Helper()
raw, err := json.Marshal(value)
require.NoError(t, err)
var clone map[string]any
require.NoError(t, json.Unmarshal(raw, &clone))
return clone
}
func assertNestedFrontendFields(t *testing.T, payload map[string]any, senderObjectType string, structured, attachment bool) {
t.Helper()
sender := payload["sender"].(map[string]any)
require.NotEmpty(t, sender["name"].(string))
require.Equal(t, senderObjectType, sender["type"])
if structured {
contentAttributes := payload["content_attributes"].(map[string]any)
require.IsType(t, []any{}, contentAttributes["e2e"].(map[string]any)["levels"])
additionalAttributes := payload["additional_attributes"].(map[string]any)
require.IsType(t, []any{}, additionalAttributes["e2e"].(map[string]any)["tags"])
externalSourceIDs := payload["external_source_ids"].(map[string]any)
require.IsType(t, []any{}, externalSourceIDs["e2e"].(map[string]any)["ids"])
}
if attachment {
attachments := payload["attachments"].([]any)
require.Len(t, attachments, 1)
item := attachments[0].(map[string]any)
require.Equal(t, "file", item["file_type"])
require.Equal(t, "txt", item["extension"])
require.IsType(t, float64(0), item["file_size"])
}
require.NotContains(t, payload, "call", "non-voice messages must not expose a call object")
}
func mapKeys(value map[string]any) []string {