- Guard fake channel with GOCHAT_ENV check: skip init() registration, bootstrap wiring, and inbox creation in production - Reject empty-token webhooks in production (was silently skipped) - Use PostgreSQL jsonb @> query for inbox lookup, keep SQLite fallback - Replace isValidURL string-prefix hack with net/url.Parse - Handle typing.start/typing.stop by returning nil (no garbage messages) - Narrow Capabilities to only implemented features (Attachments, Replies) - Hide fake channel from frontend channel list in production builds
377 lines
11 KiB
Go
377 lines
11 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gochat/gochat/internal/channel"
|
|
"github.com/gochat/gochat/internal/model"
|
|
)
|
|
|
|
func TestFakeProvider_Type(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
if p.Type() != channel.ChannelFake {
|
|
t.Fatalf("expected Type()=%s, got %s", channel.ChannelFake, p.Type())
|
|
}
|
|
if string(p.Type()) != "fake" {
|
|
t.Fatalf("expected string value 'fake', got '%s'", string(p.Type()))
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_Name(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
if p.Name() != "Fake Message Platform" {
|
|
t.Fatalf("unexpected Name(): %s", p.Name())
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_Description(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
if p.Description() == "" {
|
|
t.Fatal("Description() should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_ValidateConfig(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
ctx := context.Background()
|
|
|
|
// Missing identifier
|
|
if err := p.ValidateConfig(ctx, channel.ChannelConfig{}); err == nil {
|
|
t.Fatal("expected error for missing identifier")
|
|
}
|
|
|
|
// Valid config
|
|
if err := p.ValidateConfig(ctx, channel.ChannelConfig{
|
|
"identifier": "test_1",
|
|
"webhook_url": "http://127.0.0.1:9100/receive",
|
|
}); err != nil {
|
|
t.Fatalf("expected no error for valid config, got %v", err)
|
|
}
|
|
|
|
// Invalid webhook_url
|
|
if err := p.ValidateConfig(ctx, channel.ChannelConfig{
|
|
"identifier": "test_1",
|
|
"webhook_url": "not-a-url",
|
|
}); err == nil {
|
|
t.Fatal("expected error for invalid webhook_url")
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_DefaultConfig(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
cfg := p.DefaultConfig()
|
|
if cfg["webhook_url"] != "" {
|
|
t.Fatalf("expected default webhook_url empty, got %v", cfg["webhook_url"])
|
|
}
|
|
if cfg["identifier"] != "" {
|
|
t.Fatalf("expected default identifier empty, got %v", cfg["identifier"])
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_ConfigSchema(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
schema := p.ConfigSchema()
|
|
if schema.Type != "object" {
|
|
t.Fatalf("expected schema type 'object', got '%s'", schema.Type)
|
|
}
|
|
if _, ok := schema.Properties["webhook_url"]; !ok {
|
|
t.Fatal("expected 'webhook_url' property in schema")
|
|
}
|
|
if _, ok := schema.Properties["identifier"]; !ok {
|
|
t.Fatal("expected 'identifier' property in schema")
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_ProcessIncoming(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
inbox.AccountID = 10
|
|
|
|
payload := `{"event":"message.incoming","message_id":"msg_001","sender_id":"cust_1","sender_name":"Test Customer","content":"Hello","content_type":"text","timestamp":1720000000}`
|
|
|
|
msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload))
|
|
if err != nil {
|
|
t.Fatalf("ProcessIncoming failed: %v", err)
|
|
}
|
|
if msg.SourceID != "msg_001" {
|
|
t.Fatalf("expected SourceID 'msg_001', got '%s'", msg.SourceID)
|
|
}
|
|
if msg.SenderID != "cust_1" {
|
|
t.Fatalf("expected SenderID 'cust_1', got '%s'", msg.SenderID)
|
|
}
|
|
if msg.SenderName != "Test Customer" {
|
|
t.Fatalf("expected SenderName 'Test Customer', got '%s'", msg.SenderName)
|
|
}
|
|
if msg.Content != "Hello" {
|
|
t.Fatalf("expected Content 'Hello', got '%s'", msg.Content)
|
|
}
|
|
if msg.ContentType != channel.ContentText {
|
|
t.Fatalf("expected ContentType 'text', got '%s'", msg.ContentType)
|
|
}
|
|
if msg.InboxID != 1 {
|
|
t.Fatalf("expected InboxID 1, got %d", msg.InboxID)
|
|
}
|
|
if msg.AccountID != 10 {
|
|
t.Fatalf("expected AccountID 10, got %d", msg.AccountID)
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_ProcessIncoming_SessionEnd(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
inbox.AccountID = 10
|
|
|
|
payload := `{"event":"session.end","message_id":"msg_end","sender_id":"cust_1","content":"","timestamp":1720000000}`
|
|
|
|
msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload))
|
|
if err != nil {
|
|
t.Fatalf("ProcessIncoming failed: %v", err)
|
|
}
|
|
if msg.Content != "[session ended]" {
|
|
t.Fatalf("expected content '[session ended]', got '%s'", msg.Content)
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_ProcessIncoming_WithAttachments(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
inbox.AccountID = 10
|
|
|
|
payload := `{"event":"message.incoming","message_id":"msg_002","sender_id":"cust_1","sender_name":"Test","content":"See this","content_type":"image","attachments":[{"url":"http://example.com/img.png","content_type":"image/png","filename":"img.png","file_size":1024}]}`
|
|
|
|
msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload))
|
|
if err != nil {
|
|
t.Fatalf("ProcessIncoming failed: %v", err)
|
|
}
|
|
if len(msg.Attachments) != 1 {
|
|
t.Fatalf("expected 1 attachment, got %d", len(msg.Attachments))
|
|
}
|
|
if msg.Attachments[0].URL != "http://example.com/img.png" {
|
|
t.Fatalf("unexpected attachment URL: %s", msg.Attachments[0].URL)
|
|
}
|
|
if msg.Attachments[0].Filename != "img.png" {
|
|
t.Fatalf("unexpected filename: %s", msg.Attachments[0].Filename)
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_ValidateWebhookRequest(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
|
|
// No token configured → always valid
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
err := p.ValidateWebhookRequest(context.Background(), inbox, &channel.WebhookRequest{
|
|
Headers: map[string]string{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error with no token configured, got %v", err)
|
|
}
|
|
|
|
// Token configured, valid header
|
|
configJSON, _ := json.Marshal(map[string]string{"token": "secret123"})
|
|
inbox.ChannelConfig = string(configJSON)
|
|
err = p.ValidateWebhookRequest(context.Background(), inbox, &channel.WebhookRequest{
|
|
Headers: map[string]string{"X-Fake-Token": "secret123"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error with valid token, got %v", err)
|
|
}
|
|
|
|
// Token configured, wrong header
|
|
err = p.ValidateWebhookRequest(context.Background(), inbox, &channel.WebhookRequest{
|
|
Headers: map[string]string{"X-Fake-Token": "wrong"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error with wrong token")
|
|
}
|
|
|
|
// Token configured, lowercase header
|
|
err = p.ValidateWebhookRequest(context.Background(), inbox, &channel.WebhookRequest{
|
|
Headers: map[string]string{"x-fake-token": "secret123"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error with lowercase header, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_SendMessage_NoWebhookURL(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
msg := &model.Message{
|
|
Base: model.Base{ID: 100},
|
|
ConversationID: 50,
|
|
Content: "test",
|
|
ContentType: "text",
|
|
SenderType: "agent",
|
|
}
|
|
|
|
result, err := p.SendMessage(context.Background(), inbox, msg, nil)
|
|
if err != nil {
|
|
t.Fatalf("SendMessage without webhook_url should not error, got %v", err)
|
|
}
|
|
if result.ExternalID == "" {
|
|
t.Fatal("expected non-empty ExternalID")
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_SendMessage_PostsToWebhookURL(t *testing.T) {
|
|
// Start a test HTTP server to receive the outbound POST
|
|
var receivedBody map[string]interface{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("X-Fake-Token") != "my_token" {
|
|
t.Errorf("expected X-Fake-Token 'my_token', got '%s'", r.Header.Get("X-Fake-Token"))
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&receivedBody)
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := NewFakeProvider()
|
|
configJSON, _ := json.Marshal(map[string]string{
|
|
"webhook_url": srv.URL,
|
|
"token": "my_token",
|
|
})
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
inbox.ChannelConfig = string(configJSON)
|
|
|
|
senderID := uint(5)
|
|
msg := &model.Message{
|
|
Base: model.Base{ID: 100},
|
|
ConversationID: 50,
|
|
Content: "Hello from agent",
|
|
ContentType: "text",
|
|
SenderType: "agent",
|
|
SenderID: &senderID,
|
|
}
|
|
contact := &model.Contact{Name: "Agent Wang"}
|
|
|
|
result, err := p.SendMessage(context.Background(), inbox, msg, contact)
|
|
if err != nil {
|
|
t.Fatalf("SendMessage failed: %v", err)
|
|
}
|
|
if result.ExternalID == "" {
|
|
t.Fatal("expected non-empty ExternalID")
|
|
}
|
|
|
|
// Verify the test server received the correct payload
|
|
if receivedBody["content"] != "Hello from agent" {
|
|
t.Fatalf("expected content 'Hello from agent', got %v", receivedBody["content"])
|
|
}
|
|
if receivedBody["message_id"] != float64(100) {
|
|
t.Fatalf("expected message_id 100, got %v", receivedBody["message_id"])
|
|
}
|
|
sender, ok := receivedBody["sender"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("expected sender object")
|
|
}
|
|
if sender["type"] != "agent" {
|
|
t.Fatalf("expected sender.type 'agent', got %v", sender["type"])
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_GetContactProfile(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
|
|
profile, err := p.GetContactProfile(context.Background(), inbox, "source_id_1")
|
|
if err != nil {
|
|
t.Fatalf("GetContactProfile failed: %v", err)
|
|
}
|
|
if profile.Name != "source_id_1" {
|
|
t.Fatalf("expected name 'source_id_1', got '%s'", profile.Name)
|
|
}
|
|
if profile.Extra["source"] != "fake" {
|
|
t.Fatalf("expected source 'fake', got %v", profile.Extra["source"])
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_Capabilities(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
caps := p.Capabilities()
|
|
if !caps.SupportsAttachments {
|
|
t.Fatal("expected SupportsAttachments=true")
|
|
}
|
|
if !caps.SupportsReplies {
|
|
t.Fatal("expected SupportsReplies=true")
|
|
}
|
|
// Capabilities narrowed to only what FakeProvider actually implements.
|
|
if caps.SupportsTypingIndicator {
|
|
t.Fatal("expected SupportsTypingIndicator=false")
|
|
}
|
|
if caps.SupportsDeliveryStatus {
|
|
t.Fatal("expected SupportsDeliveryStatus=false")
|
|
}
|
|
if caps.SupportsEmojiReactions {
|
|
t.Fatal("expected SupportsEmojiReactions=false")
|
|
}
|
|
if caps.SupportsVoiceMessages {
|
|
t.Fatal("expected SupportsVoiceMessages=false")
|
|
}
|
|
if caps.SupportsCustomCards {
|
|
t.Fatal("expected SupportsCustomCards=false")
|
|
}
|
|
if caps.SupportsTemplates {
|
|
t.Fatal("expected SupportsTemplates=false")
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_ProcessIncoming_TypingStartReturnsNil(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
inbox.AccountID = 10
|
|
|
|
payload := `{"event":"typing.start","message_id":"msg_t1","sender_id":"cust_1","content":"","timestamp":1720000000}`
|
|
|
|
msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload))
|
|
if err != nil {
|
|
t.Fatalf("ProcessIncoming for typing.start should not error, got: %v", err)
|
|
}
|
|
if msg != nil {
|
|
t.Fatalf("expected nil IncomingMessage for typing.start, got: %+v", msg)
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_ProcessIncoming_TypingStopReturnsNil(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
inbox.AccountID = 10
|
|
|
|
payload := `{"event":"typing.stop","message_id":"msg_t2","sender_id":"cust_1","content":"","timestamp":1720000000}`
|
|
|
|
msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload))
|
|
if err != nil {
|
|
t.Fatalf("ProcessIncoming for typing.stop should not error, got: %v", err)
|
|
}
|
|
if msg != nil {
|
|
t.Fatalf("expected nil IncomingMessage for typing.stop, got: %+v", msg)
|
|
}
|
|
}
|
|
|
|
func TestFakeProvider_ProcessIncoming_InvalidJSON(t *testing.T) {
|
|
p := NewFakeProvider()
|
|
inbox := &model.Inbox{}
|
|
inbox.ID = 1
|
|
|
|
_, err := p.ProcessIncoming(context.Background(), inbox, []byte("not json"))
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to parse") {
|
|
t.Fatalf("expected parse error, got: %v", err)
|
|
}
|
|
}
|