Files
gochat/channels/shangwutong/internal/httpapi/server_test.go
T
2026-08-15 19:02:52 +08:00

648 lines
28 KiB
Go

package httpapi
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated"
"github.com/gochat/gochat/channels/shangwutong/internal/account"
"github.com/gochat/gochat/channels/shangwutong/internal/gochat"
"github.com/gochat/gochat/channels/shangwutong/internal/store"
)
func TestUnknownInboxIsPersistedOnlyAfterValidBootstrapSignature(t *testing.T) {
server, database, now := newTestServer(t)
body := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
response := doWebhook(t, server, body, now, "wrong")
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("invalid signature status = %d", response.StatusCode)
}
if _, err := database.Reader().GetAccountByInboxID(context.Background(), 10); err == nil {
t.Fatal("invalid bootstrap created an account")
}
response = doWebhook(t, server, body, now, "secret")
if response.StatusCode != http.StatusAccepted {
t.Fatalf("valid signature status = %d body=%s", response.StatusCode, readBody(response))
}
if _, err := database.Reader().GetAccountByInboxID(context.Background(), 10); err != nil {
t.Fatal(err)
}
}
func TestMessageWebhookIsDurableAndIdempotent(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{
ID: 77, MessageType: "outgoing", ContentType: "text", Content: "hello", Status: "progress",
Attachments: []gochat.WebhookAttachment{{
ID: 5, FileType: "audio", DataURL: "https://gochat.test/voice", FileSize: 100,
Extension: "amr", Metadata: map[string]any{"is_voice_message": true},
}},
},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
Contact: gochat.WebhookContact{ID: 99, SourceID: "visitor"},
})
first := doWebhook(t, server, message, now, "secret")
if first.StatusCode != http.StatusAccepted {
t.Fatalf("first status = %d body=%s", first.StatusCode, readBody(first))
}
var firstAck map[string]any
if err := json.NewDecoder(first.Body).Decode(&firstAck); err != nil {
t.Fatal(err)
}
_ = first.Body.Close()
if firstAck["accepted"] != true || firstAck["duplicate"] != false || firstAck["event_id"] != "message:77:created" || firstAck["delivery_id"] != "delivery" || firstAck["queue_id"] == nil {
t.Fatalf("first ACK = %#v", firstAck)
}
second := doWebhook(t, server, message, now, "secret")
if second.StatusCode != http.StatusOK {
t.Fatalf("duplicate status = %d body=%s", second.StatusCode, readBody(second))
}
var duplicateAck map[string]any
if err := json.NewDecoder(second.Body).Decode(&duplicateAck); err != nil {
t.Fatal(err)
}
_ = second.Body.Close()
if duplicateAck["duplicate"] != true || duplicateAck["queue_id"] != firstAck["queue_id"] {
t.Fatalf("duplicate ACK = %#v", duplicateAck)
}
conflicting := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "text", Content: "changed", Status: "progress"},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
conflict := doWebhook(t, server, conflicting, now, "secret")
if body := readBody(conflict); conflict.StatusCode != http.StatusConflict || !strings.Contains(body, `"code":"idempotency_conflict"`) {
t.Fatalf("conflict status=%d body=%s", conflict.StatusCode, body)
}
queued, err := database.Writer().GetOutboundByGoChatMessageID(context.Background(), 77)
if err != nil || queued.SwtSid != "visitor" {
t.Fatalf("queued = %#v, %v", queued, err)
}
parts, err := database.Reader().ListOutboundParts(context.Background(), queued.ID)
if err != nil || len(parts) != 2 || parts[0].PartType != "text" || parts[1].PartType != "audio" || parts[1].Voice != 1 {
t.Fatalf("parts = %#v, %v", parts, err)
}
}
func TestKnownInboxRejectsBadExpiredAndUnsupportedSchemaWebhooks(t *testing.T) {
server, _, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "text", Content: "hello", Status: "progress"},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
for name, response := range map[string]*http.Response{
"bad_signature": doWebhook(t, server, message, now, "wrong"),
"expired": doWebhook(t, server, message, now.Add(-6*time.Minute), "secret"),
} {
if body := readBody(response); response.StatusCode != http.StatusUnauthorized || !strings.Contains(body, `"code":"invalid_signature"`) {
t.Fatalf("%s status=%d body=%s", name, response.StatusCode, body)
}
}
var unsupported map[string]any
if err := json.Unmarshal(message, &unsupported); err != nil {
t.Fatal(err)
}
unsupported["schema_version"] = 2
unsupportedBody, _ := json.Marshal(unsupported)
response := doWebhook(t, server, unsupportedBody, now, "secret")
if body := readBody(response); response.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, `"code":"unsupported_schema_version"`) {
t.Fatalf("unsupported schema status=%d body=%s", response.StatusCode, body)
}
}
func TestMessageWebhookRejectsMoreThanResultContractAllows(t *testing.T) {
server, _, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
attachments := make([]gochat.WebhookAttachment, maxOutboundParts+1)
for index := range attachments {
attachments[index] = gochat.WebhookAttachment{
ID: int64(index + 1), FileType: "image", DataURL: "https://gochat.test/image.png",
}
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{
ID: 77, MessageType: "outgoing", ContentType: "text", Status: "progress", Attachments: attachments,
},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
response := doWebhook(t, server, message, now, "secret")
body := readBody(response)
if response.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "too_many_parts") {
t.Fatalf("status = %d body=%s", response.StatusCode, body)
}
}
func TestUnsupportedCardWebhookQueuesExplicitFailurePart(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "cards", Content: `{"title":"card"}`, Status: "progress"},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
if response := doWebhook(t, server, message, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("message status = %d body=%s", response.StatusCode, readBody(response))
}
queued, err := database.Reader().GetOutboundByGoChatMessageID(context.Background(), 77)
if err != nil {
t.Fatal(err)
}
parts, err := database.Reader().ListOutboundParts(context.Background(), queued.ID)
if err != nil || len(parts) != 1 || parts[0].PartType != "unsupported" || parts[0].Content != nil {
t.Fatalf("parts = %#v, %v", parts, err)
}
}
func TestLocationAndContactAttachmentsBecomeReadableText(t *testing.T) {
tests := []struct {
name string
contentType string
metadata map[string]any
contains []string
}{
{name: "location", contentType: "location", metadata: map[string]any{
"fallback_title": "天安门", "coordinates_lat": 39.9, "coordinates_long": 116.4,
}, contains: []string{"位置:天安门", "https://maps.google.com/?q=39.9,116.4"}},
{name: "contact", contentType: "contact", metadata: map[string]any{
"fallback_title": "+86 13800138000", "meta": map[string]any{"firstName": "张", "lastName": "三"},
}, contains: []string{"联系人:张 三", "电话:+86 13800138000"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{
ID: 77, MessageType: "outgoing", ContentType: test.contentType, Status: "progress",
Attachments: []gochat.WebhookAttachment{{ID: 5, FileType: test.contentType, Metadata: test.metadata}},
},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
if response := doWebhook(t, server, message, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("message status = %d body=%s", response.StatusCode, readBody(response))
}
queued, err := database.Reader().GetOutboundByGoChatMessageID(context.Background(), 77)
if err != nil {
t.Fatal(err)
}
parts, err := database.Reader().ListOutboundParts(context.Background(), queued.ID)
if err != nil || len(parts) != 1 || parts[0].PartType != "text" || parts[0].Content == nil {
t.Fatalf("parts = %#v, %v", parts, err)
}
for _, expected := range test.contains {
if !strings.Contains(*parts[0].Content, expected) {
t.Fatalf("fallback %q does not contain %q", *parts[0].Content, expected)
}
}
})
}
}
func TestTypingWebhookUpdatesNextSupervisorHeartbeatState(t *testing.T) {
server, _, now := newTestServer(t)
manager := &typingManagerRecorder{}
server.manager = manager
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
body := webhookBody(t, now, "conversation_typing_on", gochat.TypingWebhookData{
Conversation: gochat.WebhookConversation{ID: 100, DisplayID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
Actor: gochat.WebhookActor{ID: 7, Type: "user"},
})
response := doWebhook(t, server, body, now, "secret")
if response.StatusCode != http.StatusAccepted || manager.accountID == 0 || manager.sid != "visitor" || !manager.typing {
t.Fatalf("response=%d manager=%#v body=%s", response.StatusCode, manager, readBody(response))
}
}
func TestConversationResolveWebhookQueuesDurableEndOperation(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
body := webhookBody(t, now, "conversation_status_changed", gochat.ConversationStatusWebhookData{
Conversation: gochat.WebhookConversation{
ID: 100, DisplayID: 88, Status: "resolved", PreviousStatus: "open",
CustomAttributes: map[string]any{"swt_sid": "visitor"},
},
Actor: gochat.WebhookActor{ID: 7, Type: "user"},
})
first := doWebhook(t, server, body, now, "secret")
if first.StatusCode != http.StatusAccepted {
t.Fatalf("first status = %d body=%s", first.StatusCode, readBody(first))
}
second := doWebhook(t, server, body, now, "secret")
if second.StatusCode != http.StatusOK {
t.Fatalf("duplicate status = %d body=%s", second.StatusCode, readBody(second))
}
operation, err := database.Reader().GetOutboundOperationByEventID(context.Background(), "conversation_status_changed:1")
if err != nil || operation.Operation != "end_conversation" || operation.SwtSid != "visitor" {
t.Fatalf("operation = %#v, %v", operation, err)
}
}
func TestAcceptTransferRequestQueuesDurablyAndIsIdempotent(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
account, err := database.Reader().GetAccountByInboxID(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
seedConversationState(t, database, account.ID, "visitor", 100, "7")
seedConversationState(t, database, account.ID, "other", 200, "7")
seedConversationState(t, database, account.ID, "left", 300, "10")
payload, _ := json.Marshal(acceptTransferRequest{InboxID: 10, ConversationID: 100, SID: "visitor", EventID: "accept-transfer:visitor:1", OccurredAt: now})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
served := make(chan error, 1)
go func() { served <- server.App().Listener(listener) }()
t.Cleanup(func() {
_ = server.Shutdown(context.Background())
<-served
})
request := func(body []byte) (*http.Response, error) {
req, _ := http.NewRequest(http.MethodPost, "http://"+listener.Addr().String()+"/internal/operations/accept-transfer", bytes.NewReader(body))
return http.DefaultClient.Do(req)
}
type result struct {
response *http.Response
err error
}
const concurrentRequests = 12
start, results := make(chan struct{}), make(chan result, concurrentRequests)
for range concurrentRequests {
go func() {
<-start
response, requestErr := request(payload)
results <- result{response: response, err: requestErr}
}()
}
close(start)
accepted, duplicates, queueID := 0, 0, float64(0)
for range concurrentRequests {
result := <-results
if result.err != nil {
t.Fatal(result.err)
}
var ack map[string]any
if err := json.NewDecoder(result.response.Body).Decode(&ack); err != nil {
t.Fatal(err)
}
_ = result.response.Body.Close()
if result.response.StatusCode == http.StatusAccepted && ack["duplicate"] == false {
accepted++
} else if result.response.StatusCode == http.StatusOK && ack["duplicate"] == true {
duplicates++
} else {
t.Fatalf("concurrent response status=%d ack=%#v", result.response.StatusCode, ack)
}
if queueID == 0 {
queueID, _ = ack["queue_id"].(float64)
} else if ack["queue_id"] != queueID {
t.Fatalf("queue IDs differ: want %.0f, got %#v", queueID, ack["queue_id"])
}
}
if accepted != 1 || duplicates != concurrentRequests-1 || queueID == 0 {
t.Fatalf("accepted=%d duplicates=%d queue_id=%.0f", accepted, duplicates, queueID)
}
operation, err := database.Reader().GetOutboundOperationByEventID(context.Background(), "accept-transfer:visitor:1")
if err != nil || operation.Operation != "accept_transfer" || operation.SwtSid != "visitor" {
t.Fatalf("operation = %#v, %v", operation, err)
}
conflictingPayload, _ := json.Marshal(acceptTransferRequest{InboxID: 10, ConversationID: 200, SID: "other", EventID: "accept-transfer:visitor:1", OccurredAt: now})
conflict, err := request(conflictingPayload)
if err != nil {
t.Fatal(err)
}
if body := readBody(conflict); conflict.StatusCode != http.StatusConflict || !strings.Contains(body, `"code":"idempotency_conflict"`) {
t.Fatalf("idempotency conflict status=%d body=%s", conflict.StatusCode, body)
}
for name, test := range map[string]struct {
request acceptTransferRequest
code string
}{
"other_visitor": {acceptTransferRequest{InboxID: 10, ConversationID: 100, SID: "other", EventID: "accept-transfer:other:1", OccurredAt: now}, "session_not_found"},
"forged_sid": {acceptTransferRequest{InboxID: 10, ConversationID: 100, SID: "missing", EventID: "accept-transfer:missing:1", OccurredAt: now}, "session_not_found"},
"expired_sid": {acceptTransferRequest{InboxID: 10, ConversationID: 300, SID: "left", EventID: "accept-transfer:left:1", OccurredAt: now}, "session_not_transferable"},
} {
body, _ := json.Marshal(test.request)
response, requestErr := request(body)
if requestErr != nil {
t.Fatal(requestErr)
}
responseBody := readBody(response)
if response.StatusCode != http.StatusConflict || !strings.Contains(responseBody, `"code":"`+test.code+`"`) {
t.Fatalf("%s status=%d body=%s", name, response.StatusCode, responseBody)
}
}
if _, err := database.UpsertAccountConfig(context.Background(), store.AccountConfig{
GoChatAccountID: 1, GoChatInboxID: 10, GoChatInboxIdentifier: "identifier", ConfigVersion: 2,
SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: false,
DesiredPresence: "offline", GoChatHMACToken: "hmac", GoChatWebhookSecret: "secret",
}); err != nil {
t.Fatal(err)
}
duplicate, err := request(payload)
if err != nil {
t.Fatal(err)
}
var duplicateAck map[string]any
if err := json.NewDecoder(duplicate.Body).Decode(&duplicateAck); err != nil {
t.Fatal(err)
}
_ = duplicate.Body.Close()
if duplicate.StatusCode != http.StatusOK || duplicateAck["duplicate"] != true || duplicateAck["queue_id"] != queueID {
t.Fatalf("offline duplicate status=%d ack=%#v", duplicate.StatusCode, duplicateAck)
}
newPayload, _ := json.Marshal(acceptTransferRequest{InboxID: 10, ConversationID: 100, SID: "visitor", EventID: "accept-transfer:visitor:2", OccurredAt: now})
newRequest, err := request(newPayload)
if err != nil {
t.Fatal(err)
}
if body := readBody(newRequest); newRequest.StatusCode != http.StatusConflict || !strings.Contains(body, `"code":"account_unavailable"`) {
t.Fatalf("offline new request status=%d body=%s", newRequest.StatusCode, body)
}
invalid, err := request([]byte(`{"inbox_id":10,"conversation_id":100,"event_id":"missing-sid","occurred_at":"2026-08-15T07:00:00Z"}`))
if err != nil {
t.Fatal(err)
}
if body := readBody(invalid); invalid.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, `"code":"invalid_accept_transfer"`) {
t.Fatalf("invalid status=%d body=%s", invalid.StatusCode, body)
}
}
func TestTransferConversationRequestQueuesOtherOperatorsChat(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
account, err := database.Reader().GetAccountByInboxID(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
seedConversationState(t, database, account.ID, "visitor", 100, "5", "other-agent")
seedConversationState(t, database, account.ID, "already-transferring", 200, "7", "other-agent")
seedConversationState(t, database, account.ID, "owned", 300, "5", "agent")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
served := make(chan error, 1)
go func() { served <- server.App().Listener(listener) }()
t.Cleanup(func() {
_ = server.Shutdown(context.Background())
<-served
})
request := func(value transferConversationRequest) *http.Response {
body, _ := json.Marshal(value)
response, requestErr := http.Post("http://"+listener.Addr().String()+"/internal/operations/transfer-conversation", "application/json", bytes.NewReader(body))
if requestErr != nil {
t.Fatal(requestErr)
}
return response
}
value := transferConversationRequest{InboxID: 10, ConversationID: 100, SID: "visitor", EventID: "transfer-conversation:visitor:1", OccurredAt: now}
first := request(value)
var firstAck map[string]any
if err := json.NewDecoder(first.Body).Decode(&firstAck); err != nil {
t.Fatal(err)
}
_ = first.Body.Close()
if first.StatusCode != http.StatusAccepted || firstAck["duplicate"] != false {
t.Fatalf("first status=%d ack=%#v", first.StatusCode, firstAck)
}
duplicate := request(value)
if body := readBody(duplicate); duplicate.StatusCode != http.StatusOK || !strings.Contains(body, `"duplicate":true`) {
t.Fatalf("duplicate status=%d body=%s", duplicate.StatusCode, body)
}
operation, err := database.Reader().GetOutboundOperationByEventID(context.Background(), value.EventID)
if err != nil || operation.Operation != "transfer_conversation" || !strings.Contains(operation.Payload, `"other_login_name":"other-agent"`) {
t.Fatalf("operation=%#v err=%v", operation, err)
}
for name, test := range map[string]struct {
request transferConversationRequest
code string
}{
"wrong_state": {transferConversationRequest{InboxID: 10, ConversationID: 200, SID: "already-transferring", EventID: "transfer-conversation:state", OccurredAt: now}, "session_not_transferable"},
"already_owned": {transferConversationRequest{InboxID: 10, ConversationID: 300, SID: "owned", EventID: "transfer-conversation:owned", OccurredAt: now}, "session_not_transferable"},
"forged_sid": {transferConversationRequest{InboxID: 10, ConversationID: 100, SID: "missing", EventID: "transfer-conversation:missing", OccurredAt: now}, "session_not_found"},
} {
response := request(test.request)
body := readBody(response)
if response.StatusCode != http.StatusConflict || !strings.Contains(body, `"code":"`+test.code+`"`) {
t.Fatalf("%s status=%d body=%s", name, response.StatusCode, body)
}
}
}
func seedConversationState(t *testing.T, database *store.Store, accountID int64, sid string, conversationID int64, state string, assignee ...string) {
t.Helper()
params := dbgen.UpsertConversationMapParams{
AccountID: accountID, SwtSid: sid, GochatContactSourceID: sid, GochatConversationID: &conversationID,
}
if len(assignee) > 0 {
params.SwtAssigneeName = &assignee[0]
}
if _, err := database.Writer().UpsertConversationMap(context.Background(), params); err != nil {
t.Fatal(err)
}
if _, err := database.Writer().InsertInboundEvent(context.Background(), dbgen.InsertInboundEventParams{
AccountID: accountID, SwtSid: sid, SeqID: 1, Kind: 0, SwtEventKey: "state:" + sid + ":" + state,
Text: &state, RawLine: "test", DeliveryStatus: "delivered",
}); err != nil {
t.Fatal(err)
}
}
func TestHealthMiddlewareAddsRequestIDAndRejectsLargeBodies(t *testing.T) {
server, _, _ := newTestServer(t)
request, _ := http.NewRequest(http.MethodGet, "/healthz", nil)
response, err := server.App().Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK || response.Header.Get("X-Request-ID") == "" {
t.Fatalf("health response = %d, request-id=%q", response.StatusCode, response.Header.Get("X-Request-ID"))
}
request, _ = http.NewRequest(http.MethodPost, "/webhooks/gochat/v1", bytes.NewReader(make([]byte, maxWebhookBodyBytes+1)))
response, err = server.App().Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("large body status = %d", response.StatusCode)
}
}
func TestMetricsExposeQueueAndSupervisorGauges(t *testing.T) {
server, database, now := newTestServer(t)
server.manager = &typingManagerRecorder{}
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
account, err := database.Reader().GetAccountByInboxID(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
content := "hello"
if _, _, err := database.EnqueueOutbound(context.Background(), store.OutboundInput{
AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(),
GoChatMessageID: 77, MessageType: "text", Content: &content, Payload: `{}`,
}, false); err != nil {
t.Fatal(err)
}
if err := server.RefreshMetrics(context.Background()); err != nil {
t.Fatal(err)
}
if err := database.Close(); err != nil {
t.Fatal(err)
}
request, _ := http.NewRequest(http.MethodGet, "/metrics", nil)
response, err := server.App().Test(request)
if err != nil {
t.Fatal(err)
}
payload := readBody(response)
if response.StatusCode != http.StatusOK || !strings.Contains(payload, "swt_connector_supervisors 1") ||
!strings.Contains(payload, `swt_connector_outbound_queue_depth{status="pending"} 1`) {
t.Fatalf("status=%d metrics=%s", response.StatusCode, payload)
}
}
func newTestServer(t *testing.T) (*Server, *store.Store, time.Time) {
t.Helper()
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "connector.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
config := gochat.InboxConfig{
SchemaVersion: 1, AccountID: 1, InboxID: 10, InboxIdentifier: "identifier",
Enabled: true, DesiredPresence: "online", ConfigVersion: 1,
Credentials: gochat.Credentials{
SessionID: "BYT99917999", Username: "agent", Password: "password",
HMACToken: "hmac", WebhookSecret: "secret",
},
}
source := &testConfigSource{config: config, configs: []gochat.InboxConfig{config}}
reconciler := account.NewReconciler(source, database, nil)
server, err := NewServer(database, reconciler, nil, nil)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1785483001, 0).UTC()
server.now = func() time.Time { return now }
return server, database, now
}
type testConfigSource struct {
config gochat.InboxConfig
configs []gochat.InboxConfig
}
type typingManagerRecorder struct {
accountID int64
sid string
typing bool
}
func (*typingManagerRecorder) Running() int { return 1 }
func (m *typingManagerRecorder) SetTyping(accountID int64, sid string, typing bool) error {
m.accountID, m.sid, m.typing = accountID, sid, typing
return nil
}
func (s *testConfigSource) ListInboxConfigs(context.Context) ([]gochat.InboxConfig, error) {
return s.configs, nil
}
func (s *testConfigSource) GetInboxConfig(context.Context, int64) (gochat.InboxConfig, error) {
return s.config, nil
}
func webhookBody(t *testing.T, now time.Time, event string, data any) []byte {
t.Helper()
eventID := event + ":1"
if event == "message_created" {
eventID = "message:77:created"
}
body, err := json.Marshal(map[string]any{
"schema_version": 1, "event": event, "event_id": eventID, "occurred_at": now,
"account_id": 1, "inbox_id": 10, "data": data,
})
if err != nil {
t.Fatal(err)
}
return body
}
func doWebhook(t *testing.T, server *Server, body []byte, now time.Time, secret string) *http.Response {
t.Helper()
timestamp := strconv.FormatInt(now.Unix(), 10)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
request, _ := http.NewRequest(http.MethodPost, "/webhooks/gochat/v1", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Chatwoot-Timestamp", timestamp)
request.Header.Set("X-Chatwoot-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
request.Header.Set("X-Chatwoot-Delivery", "delivery")
if err := gochat.VerifyWebhookSignature(secret, timestamp, request.Header.Get("X-Chatwoot-Signature"), body, now); err != nil {
t.Fatalf("test signature: %v", err)
}
response, err := server.App().Test(request)
if err != nil {
t.Fatal(err)
}
return response
}
func readBody(response *http.Response) string {
payload, _ := io.ReadAll(response.Body)
_ = response.Body.Close()
return string(payload)
}