Files
gochat/backend/internal/channel/line/line_test.go
T

660 lines
22 KiB
Go

package line
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
channelpkg "github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
)
// ============================================================
// LineService tests
// ============================================================
func TestNewLineService(t *testing.T) {
svc := NewLineService(nil)
assert.NotNil(t, svc)
assert.NotNil(t, svc.client)
}
func TestLineService_ReplyMessage(t *testing.T) {
svc := NewLineService(nil)
svc.client.SetTimeout(2 * time.Second)
svc.client.SetRetryCount(0)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
assert.Equal(t, "Bearer test-token", req.Header.Get("Authorization"))
return &http.Response{
StatusCode: 200,
Body: http.NoBody,
Header: make(http.Header),
}, nil
}})
err := svc.ReplyMessage(context.Background(), "test-token", "reply-token", []OutboundMsg{{Type: "text", Text: "hello"}})
assert.NoError(t, err)
}
func TestLineService_ReplyMessage_Error(t *testing.T) {
svc := NewLineService(nil)
// The service uses real HTTP — the default URL won't resolve, but resty may retry
// Let's test the error path by using an invalid token that returns non-200
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.ReplyMessage(context.Background(), "bad-token", "reply-token", []OutboundMsg{{Type: "text", Text: "hello"}})
assert.Error(t, err)
assert.Contains(t, err.Error(), "status 401")
}
func TestLineService_PushMessage(t *testing.T) {
svc := NewLineService(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
}})
resp, err := svc.PushMessage(context.Background(), "token", "user123", []OutboundMsg{{Type: "text", Text: "hello"}})
assert.NoError(t, err)
assert.NotNil(t, resp)
}
func TestLineService_PushMessage_Error(t *testing.T) {
svc := NewLineService(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.PushMessage(context.Background(), "bad-token", "user123", []OutboundMsg{{Type: "text", Text: "hello"}})
assert.Error(t, err)
assert.Contains(t, err.Error(), "status 401")
}
func TestLineService_GetUserProfile(t *testing.T) {
svc := NewLineService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
body := `{"userId":"U123","displayName":"Test User","pictureUrl":"http://example.com/pic.jpg","statusMessage":"Hello"}`
return &http.Response{
StatusCode: 200,
Body: stringReadCloser(body),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}})
profile, err := svc.GetUserProfile(context.Background(), "token", "U123")
assert.NoError(t, err)
assert.Equal(t, "U123", profile.UserID)
assert.Equal(t, "Test User", profile.DisplayName)
assert.Equal(t, "http://example.com/pic.jpg", profile.PictureURL)
assert.Equal(t, "Hello", profile.StatusMessage)
}
func TestLineService_GetUserProfile_Error(t *testing.T) {
svc := NewLineService(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.GetUserProfile(context.Background(), "token", "invalid")
assert.Error(t, err)
assert.Contains(t, err.Error(), "status 404")
}
func TestLineService_VerifySignature(t *testing.T) {
svc := NewLineService(nil)
// Generate a valid signature for "test-body" with "secret"
body := "test-body"
secret := "test-secret"
// Compute expected signature
sig := computeHMACBase64(secret, body)
assert.True(t, svc.VerifySignature(secret, body, sig))
assert.False(t, svc.VerifySignature(secret, body, "wrong-sig"))
assert.False(t, svc.VerifySignature("wrong-secret", body, sig))
}
func TestLineService_ValidateAccessToken(t *testing.T) {
svc := NewLineService(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.ValidateAccessToken(context.Background(), "valid-token")
assert.NoError(t, err)
}
func TestLineService_ValidateAccessToken_Invalid(t *testing.T) {
svc := NewLineService(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.ValidateAccessToken(context.Background(), "invalid-token")
assert.Error(t, err)
assert.Contains(t, err.Error(), "status 401")
}
// ============================================================
// LineProvider tests
// ============================================================
func TestNewLineProvider(t *testing.T) {
svc := NewLineService(nil)
pipe := NewIncomingProcessor(svc)
provider := NewLineProvider(svc, &Repository{}, pipe)
assert.NotNil(t, provider)
}
func TestLineProvider_Identity(t *testing.T) {
provider := NewLineProvider(NewLineService(nil), &Repository{}, NewIncomingProcessor(NewLineService(nil)))
assert.Equal(t, channelpkg.ChannelLine, provider.Type())
assert.Equal(t, "LINE", provider.Name())
assert.NotEmpty(t, provider.Description())
}
func TestLineProvider_ConfigSchema(t *testing.T) {
provider := NewLineProvider(NewLineService(nil), &Repository{}, NewIncomingProcessor(NewLineService(nil)))
schema := provider.ConfigSchema()
require.NotNil(t, schema)
assert.Equal(t, "object", schema.Type)
assert.Contains(t, schema.Required, "channel_access_token")
assert.Contains(t, schema.Required, "channel_secret")
}
func TestLineProvider_ValidateConfig(t *testing.T) {
provider := NewLineProvider(NewLineService(nil), &Repository{}, NewIncomingProcessor(NewLineService(nil)))
ctx := context.Background()
t.Run("missing token", func(t *testing.T) {
err := provider.ValidateConfig(ctx, channelpkg.ChannelConfig{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "channel_access_token")
})
t.Run("missing secret", func(t *testing.T) {
err := provider.ValidateConfig(ctx, channelpkg.ChannelConfig{"channel_access_token": "token"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "channel_secret")
})
t.Run("valid config", func(t *testing.T) {
err := provider.ValidateConfig(ctx, channelpkg.ChannelConfig{
"channel_access_token": "token",
"channel_secret": "secret",
})
assert.NoError(t, err)
})
}
func TestLineProvider_DefaultConfig(t *testing.T) {
provider := NewLineProvider(NewLineService(nil), &Repository{}, NewIncomingProcessor(NewLineService(nil)))
config := provider.DefaultConfig()
assert.NotNil(t, config)
assert.Contains(t, config, "channel_access_token")
assert.Contains(t, config, "channel_secret")
}
func TestLineProvider_OnCreate(t *testing.T) {
svc := NewLineService(nil)
// Make ValidateAccessToken succeed
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 := NewLineProvider(svc, &Repository{}, NewIncomingProcessor(svc))
inbox := &model.Inbox{Base: model.Base{ID: 1}}
config := channelpkg.ChannelConfig{"channel_access_token": "token"}
result, err := provider.OnCreate(context.Background(), inbox, config)
assert.NoError(t, err)
assert.NotNil(t, result)
}
func TestLineProvider_OnDestroy(t *testing.T) {
provider := NewLineProvider(NewLineService(nil), &Repository{}, NewIncomingProcessor(NewLineService(nil)))
err := provider.OnDestroy(context.Background(), &model.Inbox{}, channelpkg.ChannelConfig{})
assert.NoError(t, err)
}
func TestLineProvider_ProcessIncoming(t *testing.T) {
svc := NewLineService(nil)
pipe := NewIncomingProcessor(svc)
provider := NewLineProvider(svc, &Repository{}, pipe)
ctx := context.Background()
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
t.Run("valid text message", func(t *testing.T) {
event := WebhookEvent{
Events: []EventObject{
{
Type: "message",
ReplyToken: "rt-123",
Source: EventSource{Type: "user", UserID: "U123"},
Message: json.RawMessage(`{"type":"text","id":"msg-1","text":"Hello LINE"}`),
},
},
}
payload, _ := json.Marshal(event)
msg, err := provider.ProcessIncoming(ctx, inbox, payload)
require.NoError(t, err)
require.NotNil(t, msg)
assert.Equal(t, "msg-1", msg.SourceID)
assert.Equal(t, "U123", msg.SenderID)
assert.Equal(t, "Hello LINE", msg.Content)
assert.Equal(t, channelpkg.ContentText, msg.ContentType)
assert.Equal(t, "rt-123", msg.Extra["reply_token"])
})
t.Run("no events", func(t *testing.T) {
payload, _ := json.Marshal(WebhookEvent{Events: []EventObject{}})
_, err := provider.ProcessIncoming(ctx, inbox, payload)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no events")
})
t.Run("invalid JSON", func(t *testing.T) {
_, err := provider.ProcessIncoming(ctx, inbox, []byte("invalid"))
assert.Error(t, err)
})
t.Run("follow event", func(t *testing.T) {
event := WebhookEvent{
Events: []EventObject{
{Type: "follow", Source: EventSource{Type: "user", UserID: "U456"}},
},
}
payload, _ := json.Marshal(event)
msg, err := provider.ProcessIncoming(ctx, inbox, payload)
assert.NoError(t, err)
assert.Nil(t, msg) // follow events return nil
})
t.Run("unfollow event", func(t *testing.T) {
event := WebhookEvent{
Events: []EventObject{
{Type: "unfollow", Source: EventSource{Type: "user", UserID: "U789"}},
},
}
payload, _ := json.Marshal(event)
msg, err := provider.ProcessIncoming(ctx, inbox, payload)
assert.NoError(t, err)
assert.Nil(t, msg)
})
t.Run("postback event", func(t *testing.T) {
event := WebhookEvent{
Events: []EventObject{
{
Type: "postback",
Source: EventSource{Type: "user", UserID: "U000"},
Postback: &PostbackData{Data: "action=buy"},
},
},
}
payload, _ := json.Marshal(event)
msg, err := provider.ProcessIncoming(ctx, inbox, payload)
require.NoError(t, err)
require.NotNil(t, msg)
assert.Equal(t, "action=buy", msg.Content)
})
}
func TestLineProvider_ValidateWebhookRequest(t *testing.T) {
svc := NewLineService(nil)
provider := NewLineProvider(svc, &Repository{}, NewIncomingProcessor(svc))
ctx := context.Background()
inbox := &model.Inbox{Base: model.Base{ID: 1}}
t.Run("missing secret", func(t *testing.T) {
err := provider.ValidateWebhookRequest(ctx, inbox, &channelpkg.WebhookRequest{
Headers: map[string]string{"X-Line-Signature": "sig"},
Body: []byte("body"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "channel_secret")
})
t.Run("missing signature", func(t *testing.T) {
inboxWithConfig := &model.Inbox{
Base: model.Base{ID: 1},
ChannelConfig: `{"channel_secret":"secret"}`,
}
err := provider.ValidateWebhookRequest(ctx, inboxWithConfig, &channelpkg.WebhookRequest{
Headers: map[string]string{},
Body: []byte("body"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing X-Line-Signature")
})
t.Run("invalid signature", func(t *testing.T) {
inboxWithConfig := &model.Inbox{
Base: model.Base{ID: 1},
ChannelConfig: `{"channel_secret":"secret"}`,
}
err := provider.ValidateWebhookRequest(ctx, inboxWithConfig, &channelpkg.WebhookRequest{
Headers: map[string]string{"X-Line-Signature": "wrong-sig"},
Body: []byte("body"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid HMAC")
})
t.Run("valid signature", func(t *testing.T) {
body := "test-body"
secret := "test-secret"
sig := computeHMACBase64(secret, body)
inboxWithConfig := &model.Inbox{
Base: model.Base{ID: 1},
ChannelConfig: `{"channel_secret":"test-secret"}`,
}
err := provider.ValidateWebhookRequest(ctx, inboxWithConfig, &channelpkg.WebhookRequest{
Headers: map[string]string{"X-Line-Signature": sig},
Body: []byte(body),
})
assert.NoError(t, err)
})
}
func TestLineProvider_SendMessage(t *testing.T) {
svc := NewLineService(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 := NewLineProvider(svc, &Repository{}, NewIncomingProcessor(svc))
ctx := context.Background()
inbox := &model.Inbox{
Base: model.Base{ID: 1},
ChannelConfig: `{"channel_access_token":"token"}`,
}
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", func(t *testing.T) {
contact := &model.Contact{SourceID: "U123"}
msg := &model.Message{Content: "Hello"}
result, err := provider.SendMessage(ctx, inbox, msg, contact)
require.NoError(t, err)
assert.NotNil(t, result)
})
}
func TestLineProvider_GetContactProfile(t *testing.T) {
svc := NewLineService(nil)
svc.client.SetTransport(&testTransport{handler: func(req *http.Request) (*http.Response, error) {
body := `{"userId":"U123","displayName":"Test User","pictureUrl":"http://pic.jpg","statusMessage":"Hi"}`
return &http.Response{
StatusCode: 200,
Body: stringReadCloser(body),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}})
provider := NewLineProvider(svc, &Repository{}, NewIncomingProcessor(svc))
inbox := &model.Inbox{
Base: model.Base{ID: 1},
ChannelConfig: `{"channel_access_token":"token"}`,
}
profile, err := provider.GetContactProfile(context.Background(), inbox, "U123")
require.NoError(t, err)
assert.Equal(t, "Test User", profile.Name)
assert.Equal(t, "http://pic.jpg", profile.AvatarURL)
assert.Equal(t, "Hi", profile.Extra["status_message"])
}
func TestLineProvider_Capabilities(t *testing.T) {
provider := NewLineProvider(NewLineService(nil), &Repository{}, NewIncomingProcessor(NewLineService(nil)))
caps := provider.Capabilities()
assert.True(t, caps.SupportsAttachments)
assert.True(t, caps.SupportsLocation)
assert.True(t, caps.SupportsReplies)
assert.True(t, caps.SupportsVoiceMessages)
assert.True(t, caps.SupportsCustomCards)
assert.True(t, caps.SupportsTemplates)
assert.Equal(t, int64(10*1024*1024), caps.MaxAttachmentSize)
assert.Equal(t, 5000, caps.MaxTextLength)
}
// ============================================================
// Pipeline tests
// ============================================================
func TestNewIncomingProcessor(t *testing.T) {
p := NewIncomingProcessor(NewLineService(nil))
assert.NotNil(t, p)
}
func TestIncomingProcessor_ProcessEvent_NoSourceID(t *testing.T) {
p := NewIncomingProcessor(NewLineService(nil))
inbox := &model.Inbox{Base: model.Base{ID: 1}}
_, err := p.ProcessEvent(context.Background(), inbox, EventObject{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no source ID")
}
func TestIncomingProcessor_ProcessEvent_MessageTypes(t *testing.T) {
p := NewIncomingProcessor(NewLineService(nil))
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
t.Run("text message", func(t *testing.T) {
event := EventObject{
Type: "message",
Source: EventSource{Type: "user", UserID: "U1"},
Message: json.RawMessage(`{"type":"text","id":"m1","text":"hello"}`),
}
msg, err := p.ProcessEvent(context.Background(), inbox, event)
require.NoError(t, err)
assert.Equal(t, "hello", msg.Content)
assert.Equal(t, channelpkg.ContentText, msg.ContentType)
})
t.Run("image message", func(t *testing.T) {
event := EventObject{
Type: "message",
Source: EventSource{Type: "user", UserID: "U1"},
Message: json.RawMessage(`{"type":"image","id":"m2","originalContentUrl":"http://img.jpg"}`),
}
msg, err := p.ProcessEvent(context.Background(), inbox, event)
require.NoError(t, err)
assert.Equal(t, channelpkg.ContentImage, msg.ContentType)
assert.Len(t, msg.Attachments, 1)
})
t.Run("video message", func(t *testing.T) {
event := EventObject{
Type: "message",
Source: EventSource{Type: "user", UserID: "U1"},
Message: json.RawMessage(`{"type":"video","id":"m3","originalContentUrl":"http://vid.mp4"}`),
}
msg, err := p.ProcessEvent(context.Background(), inbox, event)
require.NoError(t, err)
assert.Equal(t, channelpkg.ContentVideo, msg.ContentType)
})
t.Run("audio message", func(t *testing.T) {
event := EventObject{
Type: "message",
Source: EventSource{Type: "user", UserID: "U1"},
Message: json.RawMessage(`{"type":"audio","id":"m4","originalContentUrl":"http://au.mp3","duration":5000}`),
}
msg, err := p.ProcessEvent(context.Background(), inbox, event)
require.NoError(t, err)
assert.Equal(t, channelpkg.ContentAudio, msg.ContentType)
assert.Contains(t, msg.Content, "Audio message")
})
t.Run("file message", func(t *testing.T) {
event := EventObject{
Type: "message",
Source: EventSource{Type: "user", UserID: "U1"},
Message: json.RawMessage(`{"type":"file","id":"m5","fileName":"doc.pdf","fileSize":1024,"originalContentUrl":"http://file.pdf"}`),
}
msg, err := p.ProcessEvent(context.Background(), inbox, event)
require.NoError(t, err)
assert.Equal(t, channelpkg.ContentFile, msg.ContentType)
assert.Equal(t, "doc.pdf", msg.Content)
})
t.Run("location message", func(t *testing.T) {
event := EventObject{
Type: "message",
Source: EventSource{Type: "user", UserID: "U1"},
Message: json.RawMessage(`{"type":"location","id":"m6","title":"Office","address":"123 Main St","latitude":35.6762,"longitude":139.6503}`),
}
msg, err := p.ProcessEvent(context.Background(), inbox, event)
require.NoError(t, err)
assert.Equal(t, channelpkg.ContentLocation, msg.ContentType)
assert.Contains(t, msg.Content, "Office")
})
t.Run("sticker message", func(t *testing.T) {
event := EventObject{
Type: "message",
Source: EventSource{Type: "user", UserID: "U1"},
Message: json.RawMessage(`{"type":"sticker","id":"m7","packageId":"1","stickerId":"100"}`),
}
msg, err := p.ProcessEvent(context.Background(), inbox, event)
require.NoError(t, err)
assert.Contains(t, msg.Content, "Sticker")
})
t.Run("unknown message type", func(t *testing.T) {
event := EventObject{
Type: "message",
Source: EventSource{Type: "user", UserID: "U1"},
Message: json.RawMessage(`{"type":"unknown","id":"m8"}`),
}
msg, err := p.ProcessEvent(context.Background(), inbox, event)
require.NoError(t, err)
assert.Contains(t, msg.Content, "Unsupported")
})
t.Run("invalid message JSON", func(t *testing.T) {
event := EventObject{
Type: "message",
Source: EventSource{Type: "user", UserID: "U1"},
Message: json.RawMessage(`invalid`),
}
_, err := p.ProcessEvent(context.Background(), inbox, event)
assert.Error(t, err)
})
t.Run("unhandled event type", func(t *testing.T) {
event := EventObject{
Type: "beacon",
Source: EventSource{Type: "user", UserID: "U1"},
}
msg, err := p.ProcessEvent(context.Background(), inbox, event)
assert.NoError(t, err)
assert.Nil(t, msg)
})
}
func TestResolveSourceID(t *testing.T) {
assert.Equal(t, "U123", resolveSourceID(EventSource{Type: "user", UserID: "U123"}))
assert.Equal(t, "G456", resolveSourceID(EventSource{Type: "group", GroupID: "G456"}))
assert.Equal(t, "R789", resolveSourceID(EventSource{Type: "room", RoomID: "R789"}))
assert.Equal(t, "", resolveSourceID(EventSource{}))
}
func TestNewOutgoingProcessor(t *testing.T) {
p := NewOutgoingProcessor(NewLineService(nil))
assert.NotNil(t, p)
}
func TestOutgoingProcessor_SendMessage_NoSourceID(t *testing.T) {
p := NewOutgoingProcessor(NewLineService(nil))
inbox := &model.Inbox{Base: model.Base{ID: 1}}
msg := &model.Message{Content: "test"}
contact := &model.Contact{}
_, err := p.SendMessage(context.Background(), inbox, msg, contact)
assert.Error(t, err)
}
func TestNewEventBridge(t *testing.T) {
b := NewEventBridge(NewOutgoingProcessor(NewLineService(nil)))
assert.NotNil(t, b)
}
// ============================================================
// 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; skip nil DB tests to avoid panics
// ============================================================
// ============================================================
// 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)
}
func stringReadCloser(s string) io.ReadCloser {
return io.NopCloser(strings.NewReader(s))
}
func computeHMACBase64(secret, body string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}