Files
gochat/backend/internal/channel/twilio/twilio_test.go
T

581 lines
19 KiB
Go

package twilio
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
channelpkg "github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
)
// ============================================================
// TwilioService tests
// ============================================================
func TestNewTwilioService(t *testing.T) {
svc := NewTwilioService(nil)
assert.NotNil(t, svc)
assert.NotNil(t, svc.client)
}
func TestTwilioService_SendMessage(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 201,
Body: io.NopCloser(strings.NewReader(`{"sid":"SM123","status":"queued","to":"+1234567890","from":"+1987654321"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}})
result, err := svc.SendMessage(context.Background(), "AC123", "token", "+1987654321", "+1234567890", "Hello", nil)
require.NoError(t, err)
assert.Equal(t, "SM123", result.SID)
assert.Equal(t, "queued", result.Status)
}
func TestTwilioService_SendMessage_WithMedia(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 201,
Body: io.NopCloser(strings.NewReader(`{"sid":"SM456","status":"queued"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}})
result, err := svc.SendMessage(context.Background(), "AC123", "token", "+1987654321", "+1234567890", "MMS body", []string{"http://img.jpg"})
require.NoError(t, err)
assert.Equal(t, "SM456", result.SID)
}
func TestTwilioService_SendMessage_APIError(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 400,
Body: io.NopCloser(strings.NewReader(`{"code":21211,"message":"Invalid phone number"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}})
_, err := svc.SendMessage(context.Background(), "AC123", "token", "+1987654321", "invalid", "Hello", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Invalid phone number")
}
func TestTwilioService_SendMessage_NonJSONError(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 500,
Body: io.NopCloser(strings.NewReader("Internal Server Error")),
Header: make(http.Header),
}, nil
}})
_, err := svc.SendMessage(context.Background(), "AC123", "token", "+1987654321", "+1234567890", "Hello", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "status 500")
}
func TestTwilioService_GetPhoneNumberInfo(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{"sid":"PN123","phoneNumber":"+1987654321","friendlyName":"Support Line"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}})
info, err := svc.GetPhoneNumberInfo(context.Background(), "AC123", "token", "PN123")
require.NoError(t, err)
assert.Equal(t, "PN123", info.SID)
assert.Equal(t, "+1987654321", info.PhoneNumber)
assert.Equal(t, "Support Line", info.FriendlyName)
}
func TestTwilioService_GetPhoneNumberInfo_Error(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 404,
Body: http.NoBody,
Header: make(http.Header),
}, nil
}})
_, err := svc.GetPhoneNumberInfo(context.Background(), "AC123", "token", "PN000")
assert.Error(t, err)
assert.Contains(t, err.Error(), "status 404")
}
func TestTwilioService_ValidateCredentials(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: http.NoBody,
Header: make(http.Header),
}, nil
}})
err := svc.ValidateCredentials(context.Background(), "AC123", "token")
assert.NoError(t, err)
}
func TestTwilioService_ValidateCredentials_Invalid(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 401,
Body: http.NoBody,
Header: make(http.Header),
}, nil
}})
err := svc.ValidateCredentials(context.Background(), "AC123", "bad-token")
assert.Error(t, err)
assert.Contains(t, err.Error(), "status 401")
}
func TestTwilioService_ParsePhoneNumber(t *testing.T) {
svc := NewTwilioService(nil)
tests := []struct {
input string
want string
}{
{"+1-234-567-8900", "+12345678900"},
{"(123) 456-7890", "+1234567890"},
{"123.456.7890", "+1234567890"},
{"+1234567890", "+1234567890"},
{"1234567890", "+1234567890"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, svc.ParsePhoneNumber(tt.input), "input=%q", tt.input)
}
}
// ============================================================
// TwilioProvider tests
// ============================================================
func TestNewTwilioProvider(t *testing.T) {
svc := NewTwilioService(nil)
pipe := NewIncomingProcessor(svc)
provider := NewTwilioProvider(svc, &Repository{}, pipe)
assert.NotNil(t, provider)
}
func TestTwilioProvider_Identity(t *testing.T) {
provider := NewTwilioProvider(NewTwilioService(nil), &Repository{}, NewIncomingProcessor(NewTwilioService(nil)))
assert.Equal(t, channelpkg.ChannelTwilioSMS, provider.Type())
assert.Equal(t, "Twilio SMS", provider.Name())
assert.NotEmpty(t, provider.Description())
}
func TestTwilioProvider_ConfigSchema(t *testing.T) {
provider := NewTwilioProvider(NewTwilioService(nil), &Repository{}, NewIncomingProcessor(NewTwilioService(nil)))
schema := provider.ConfigSchema()
require.NotNil(t, schema)
assert.Equal(t, "object", schema.Type)
assert.Contains(t, schema.Required, "account_sid")
assert.Contains(t, schema.Required, "auth_token")
assert.Contains(t, schema.Required, "phone_number")
}
func TestTwilioProvider_ValidateConfig(t *testing.T) {
provider := NewTwilioProvider(NewTwilioService(nil), &Repository{}, NewIncomingProcessor(NewTwilioService(nil)))
ctx := context.Background()
t.Run("missing account_sid", func(t *testing.T) {
err := provider.ValidateConfig(ctx, channelpkg.ChannelConfig{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "account_sid")
})
t.Run("missing auth_token", func(t *testing.T) {
err := provider.ValidateConfig(ctx, channelpkg.ChannelConfig{"account_sid": "AC123"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "auth_token")
})
t.Run("missing phone_number", func(t *testing.T) {
err := provider.ValidateConfig(ctx, channelpkg.ChannelConfig{"account_sid": "AC123", "auth_token": "token"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "phone_number")
})
t.Run("invalid account_sid prefix", func(t *testing.T) {
err := provider.ValidateConfig(ctx, channelpkg.ChannelConfig{
"account_sid": "XX123",
"auth_token": "token",
"phone_number": "+1234567890",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "must start with 'AC'")
})
t.Run("valid config", func(t *testing.T) {
err := provider.ValidateConfig(ctx, channelpkg.ChannelConfig{
"account_sid": "AC123",
"auth_token": "token",
"phone_number": "+1234567890",
})
assert.NoError(t, err)
})
}
func TestTwilioProvider_DefaultConfig(t *testing.T) {
provider := NewTwilioProvider(NewTwilioService(nil), &Repository{}, NewIncomingProcessor(NewTwilioService(nil)))
config := provider.DefaultConfig()
assert.NotNil(t, config)
assert.Contains(t, config, "account_sid")
assert.Contains(t, config, "auth_token")
assert.Contains(t, config, "phone_number")
}
func TestTwilioProvider_OnCreate(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: http.NoBody, Header: make(http.Header)}, nil
}})
provider := NewTwilioProvider(svc, &Repository{}, NewIncomingProcessor(svc))
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
config := channelpkg.ChannelConfig{
"account_sid": "AC123",
"auth_token": "token",
"phone_number": "+1234567890",
}
result, err := provider.OnCreate(context.Background(), inbox, config)
assert.NoError(t, err)
assert.NotNil(t, result)
}
func TestTwilioProvider_OnCreate_InvalidCredentials(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 401, Body: http.NoBody, Header: make(http.Header)}, nil
}})
provider := NewTwilioProvider(svc, &Repository{}, NewIncomingProcessor(svc))
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
config := channelpkg.ChannelConfig{
"account_sid": "AC123",
"auth_token": "bad-token",
"phone_number": "+1234567890",
}
result, err := provider.OnCreate(context.Background(), inbox, config)
assert.NoError(t, err)
assert.Equal(t, true, result["reauthorization_required"])
}
func TestTwilioProvider_OnDestroy(t *testing.T) {
provider := NewTwilioProvider(NewTwilioService(nil), &Repository{}, NewIncomingProcessor(NewTwilioService(nil)))
err := provider.OnDestroy(context.Background(), &model.Inbox{}, channelpkg.ChannelConfig{})
assert.NoError(t, err)
}
func TestTwilioProvider_ProcessIncoming(t *testing.T) {
svc := NewTwilioService(nil)
pipe := NewIncomingProcessor(svc)
provider := NewTwilioProvider(svc, &Repository{}, pipe)
ctx := context.Background()
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
t.Run("valid SMS", func(t *testing.T) {
formData := "MessageSid=SM123&AccountSid=AC123&From=%2B1234567890&To=%2B1987654321&Body=Hello+world&NumMedia=0&SmsStatus=received"
msg, err := provider.ProcessIncoming(ctx, inbox, []byte(formData))
require.NoError(t, err)
require.NotNil(t, msg)
assert.Equal(t, "SM123", msg.SourceID)
assert.Equal(t, "+1234567890", msg.SenderID)
assert.Equal(t, "Hello world", msg.Content)
assert.Equal(t, channelpkg.ContentText, msg.ContentType)
})
t.Run("invalid form data", func(t *testing.T) {
_, err := provider.ProcessIncoming(ctx, inbox, []byte("%"))
assert.Error(t, err)
})
}
func TestTwilioProvider_ValidateWebhookRequest(t *testing.T) {
provider := NewTwilioProvider(NewTwilioService(nil), &Repository{}, NewIncomingProcessor(NewTwilioService(nil)))
err := provider.ValidateWebhookRequest(context.Background(), &model.Inbox{}, &channelpkg.WebhookRequest{})
assert.NoError(t, err) // TODO: always returns nil
}
func TestTwilioProvider_SendMessage(t *testing.T) {
svc := NewTwilioService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 201,
Body: io.NopCloser(strings.NewReader(`{"sid":"SM999","status":"queued"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}})
provider := NewTwilioProvider(svc, &Repository{}, NewIncomingProcessor(svc))
ctx := context.Background()
inbox := &model.Inbox{
Base: model.Base{ID: 1},
ChannelConfig: `{"account_sid":"AC123","auth_token":"token","phone_number":"+1987654321"}`,
}
t.Run("no contact source_id", func(t *testing.T) {
contact := &model.Contact{}
msg := &model.Message{Content: "test"}
_, err := provider.SendMessage(ctx, inbox, msg, contact)
assert.Error(t, err)
})
t.Run("valid send with phone number", func(t *testing.T) {
contact := &model.Contact{SourceID: "+1234567890"}
msg := &model.Message{Content: "Hello SMS"}
result, err := provider.SendMessage(ctx, inbox, msg, contact)
require.NoError(t, err)
assert.Equal(t, "SM999", result.ExternalID)
})
t.Run("valid send with messaging service SID", func(t *testing.T) {
inboxWithMS := &model.Inbox{
Base: model.Base{ID: 1},
ChannelConfig: `{"account_sid":"AC123","auth_token":"token","phone_number":"+1987654321","messaging_service_sid":"MG123"}`,
}
contact := &model.Contact{SourceID: "+1234567890"}
msg := &model.Message{Content: "Hello MMS"}
result, err := provider.SendMessage(ctx, inboxWithMS, msg, contact)
require.NoError(t, err)
assert.Equal(t, "SM999", result.ExternalID)
})
}
func TestTwilioProvider_GetContactProfile(t *testing.T) {
svc := NewTwilioService(nil)
provider := NewTwilioProvider(svc, &Repository{}, NewIncomingProcessor(svc))
profile, err := provider.GetContactProfile(context.Background(), &model.Inbox{}, "+1-234-567-8900")
require.NoError(t, err)
assert.Equal(t, "+12345678900", profile.Name)
assert.Equal(t, "+12345678900", profile.Extra["phone_number"])
}
func TestTwilioProvider_Capabilities(t *testing.T) {
provider := NewTwilioProvider(NewTwilioService(nil), &Repository{}, NewIncomingProcessor(NewTwilioService(nil)))
caps := provider.Capabilities()
assert.True(t, caps.SupportsAttachments)
assert.True(t, caps.SupportsDeliveryStatus)
assert.Equal(t, int64(5*1024*1024), caps.MaxAttachmentSize)
assert.Equal(t, 160, caps.MaxTextLength)
}
// ============================================================
// Pipeline tests
// ============================================================
func TestNewIncomingProcessor(t *testing.T) {
p := NewIncomingProcessor(NewTwilioService(nil))
assert.NotNil(t, p)
}
func TestIncomingProcessor_ProcessInboundSMS(t *testing.T) {
svc := NewTwilioService(nil)
p := NewIncomingProcessor(svc)
ctx := context.Background()
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
t.Run("text SMS", func(t *testing.T) {
sms := InboundSMS{
MessageSID: "SM123",
AccountSID: "AC123",
From: "+1234567890",
To: "+1987654321",
Body: "Hello",
NumMedia: "0",
}
msg, err := p.ProcessInboundSMS(ctx, inbox, sms)
require.NoError(t, err)
assert.Equal(t, "SM123", msg.SourceID)
assert.Equal(t, "Hello", msg.Content)
assert.Equal(t, channelpkg.ContentText, msg.ContentType)
})
t.Run("MMS with media", func(t *testing.T) {
sms := InboundSMS{
MessageSID: "SM456",
From: "+1234567890",
Body: "MMS text",
NumMedia: "2",
MediaURL: []string{"http://img1.jpg", "http://img2.jpg"},
MediaContentType: []string{"image/jpeg", "image/png"},
}
msg, err := p.ProcessInboundSMS(ctx, inbox, sms)
require.NoError(t, err)
assert.Equal(t, channelpkg.ContentImage, msg.ContentType)
assert.Equal(t, "http://img1.jpg", msg.Content)
assert.Len(t, msg.Attachments, 2)
assert.Equal(t, "MMS text", msg.Extra["body"])
})
t.Run("MMS with invalid NumMedia", func(t *testing.T) {
sms := InboundSMS{
MessageSID: "SM789",
From: "+1234567890",
Body: "Text",
NumMedia: "abc",
}
msg, err := p.ProcessInboundSMS(ctx, inbox, sms)
require.NoError(t, err)
assert.Equal(t, channelpkg.ContentText, msg.ContentType)
})
t.Run("no From number", func(t *testing.T) {
sms := InboundSMS{}
_, err := p.ProcessInboundSMS(ctx, inbox, sms)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no From number")
})
}
func TestIncomingProcessor_ProcessDeliveryStatus(t *testing.T) {
svc := NewTwilioService(nil)
p := NewIncomingProcessor(svc)
ctx := context.Background()
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
t.Run("delivered", func(t *testing.T) {
status := DeliveryStatus{
MessageSID: "SM123",
MessageStatus: "delivered",
To: "+1234567890",
}
msg, err := p.ProcessDeliveryStatus(ctx, inbox, status)
require.NoError(t, err)
require.NotNil(t, msg)
assert.Contains(t, msg.Content, "delivered")
assert.Equal(t, channelpkg.SenderSystem, msg.SenderType)
})
t.Run("failed", func(t *testing.T) {
status := DeliveryStatus{
MessageSID: "SM456",
MessageStatus: "failed",
ErrorCode: "30001",
To: "+1234567890",
}
msg, err := p.ProcessDeliveryStatus(ctx, inbox, status)
require.NoError(t, err)
require.NotNil(t, msg)
assert.Contains(t, msg.Content, "failed")
assert.Equal(t, "30001", msg.Extra["error_code"])
})
t.Run("undelivered", func(t *testing.T) {
status := DeliveryStatus{
MessageSID: "SM789",
MessageStatus: "undelivered",
To: "+1234567890",
}
msg, err := p.ProcessDeliveryStatus(ctx, inbox, status)
require.NoError(t, err)
require.NotNil(t, msg)
assert.Contains(t, msg.Content, "failed")
})
t.Run("unhandled status", func(t *testing.T) {
status := DeliveryStatus{
MessageSID: "SM000",
MessageStatus: "sent",
To: "+1234567890",
}
msg, err := p.ProcessDeliveryStatus(ctx, inbox, status)
assert.NoError(t, err)
assert.Nil(t, msg)
})
}
// ============================================================
// Helper functions tests
// ============================================================
func TestParseInboxConfig(t *testing.T) {
assert.Empty(t, parseInboxConfig(""))
assert.NotEmpty(t, parseInboxConfig(`{"key":"val"}`))
assert.Empty(t, parseInboxConfig("invalid"))
}
func TestConfigStr(t *testing.T) {
assert.Equal(t, "default", configStr(channelpkg.ChannelConfig{}, "key", "default"))
assert.Equal(t, "val", configStr(channelpkg.ChannelConfig{"key": "val"}, "key", "default"))
assert.Equal(t, "default", configStr(channelpkg.ChannelConfig{"key": ""}, "key", "default"))
}
// ============================================================
// Repository tests — require a DB
// ============================================================
// ============================================================
// Types tests
// ============================================================
func TestInboundSMS_JSON(t *testing.T) {
sms := InboundSMS{
MessageSID: "SM123",
From: "+1234567890",
Body: "Hello",
}
data, err := json.Marshal(sms)
require.NoError(t, err)
assert.Contains(t, string(data), "SM123")
var decoded InboundSMS
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, sms.MessageSID, decoded.MessageSID)
}
func TestOutboundSMSResponse_JSON(t *testing.T) {
resp := OutboundSMSResponse{
SID: "SM123",
Status: "queued",
To: "+1234567890",
}
data, err := json.Marshal(resp)
require.NoError(t, err)
var decoded OutboundSMSResponse
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, resp.SID, decoded.SID)
}
func TestDeliveryStatus_JSON(t *testing.T) {
status := DeliveryStatus{
MessageSID: "SM123",
MessageStatus: "delivered",
}
data, err := json.Marshal(status)
require.NoError(t, err)
var decoded DeliveryStatus
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, status.MessageSID, decoded.MessageSID)
}
// ============================================================
// Test helpers
// ============================================================
type testTransport struct {
handler func(*http.Request) (*http.Response, error)
}
func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.handler(req)
}