460 lines
16 KiB
Go
460 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
)
|
|
|
|
// ========================================
|
|
// Shared test helpers
|
|
// ========================================
|
|
|
|
func setupLinearNotionTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
|
|
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err, "failed to open SQLite test database")
|
|
|
|
require.NoError(t, db.AutoMigrate(
|
|
&model.Account{},
|
|
&model.User{},
|
|
&model.Contact{},
|
|
&model.Inbox{},
|
|
&model.Conversation{},
|
|
&model.Message{},
|
|
&model.IntegrationHook{},
|
|
), "failed to auto-migrate models")
|
|
|
|
t.Cleanup(func() {
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.Close()
|
|
})
|
|
|
|
return db
|
|
}
|
|
|
|
func seedLinearNotionAccount(db *gorm.DB, t *testing.T) uint {
|
|
t.Helper()
|
|
account := &model.Account{Name: "Test Account"}
|
|
require.NoError(t, db.Create(account).Error, "failed to seed account")
|
|
return account.ID
|
|
}
|
|
|
|
func seedLinearHook(db *gorm.DB, t *testing.T, accountID uint) uint {
|
|
t.Helper()
|
|
settings := model.LinearSettings{
|
|
TeamID: "team-1",
|
|
TeamName: "Engineering",
|
|
AccessToken: "lin_token_123",
|
|
}
|
|
settingsJSON, err := json.Marshal(settings)
|
|
require.NoError(t, err, "failed to marshal Linear settings")
|
|
|
|
hook := &model.IntegrationHook{
|
|
AccountID: accountID,
|
|
AppID: "linear",
|
|
HookType: model.HookTypeLinear,
|
|
Status: model.HookStatusActive,
|
|
AccessToken: "lin_token_123",
|
|
Settings: settingsJSON,
|
|
}
|
|
require.NoError(t, db.Create(hook).Error, "failed to seed Linear hook")
|
|
return hook.ID
|
|
}
|
|
|
|
func seedLinearConversation(db *gorm.DB, t *testing.T, accountID uint) *model.Conversation {
|
|
t.Helper()
|
|
inbox := &model.Inbox{AccountID: accountID, Name: "Support", ChannelType: "web_widget"}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
contact := &model.Contact{AccountID: accountID, Name: "Jane"}
|
|
require.NoError(t, db.Create(contact).Error)
|
|
displayID := uint(42)
|
|
conversation := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, ChannelType: "web_widget", Channel: "web_widget"}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
return conversation
|
|
}
|
|
|
|
func seedLinearUser(db *gorm.DB, t *testing.T, accountID uint) *model.User {
|
|
t.Helper()
|
|
user := &model.User{AccountID: accountID, Name: "Agent Smith", Email: "agent@example.test", Password: "secret"}
|
|
require.NoError(t, db.Create(user).Error)
|
|
return user
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
|
|
|
func fakeLinearHTTPClient(t *testing.T) *http.Client {
|
|
t.Helper()
|
|
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/oauth/revoke" {
|
|
return fakeHTTPResponse(http.StatusOK, `{}`), nil
|
|
}
|
|
var payload map[string]string
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
|
query := payload["query"]
|
|
switch {
|
|
case strings.Contains(query, "teams") && !strings.Contains(query, "workflowStates"):
|
|
return fakeHTTPResponse(http.StatusOK, `{"data":{"teams":{"nodes":[{"id":"team-1","name":"Engineering"}]}}}`), nil
|
|
case strings.Contains(query, "workflowStates"):
|
|
return fakeHTTPResponse(http.StatusOK, `{"data":{"users":{"nodes":[{"id":"user-1","name":"User One"}]},"projects":{"nodes":[{"id":"project-1","name":"Project One"}]},"workflowStates":{"nodes":[{"id":"state-1","name":"Started"}]},"issueLabels":{"nodes":[{"id":"label-1","name":"Bug"}]}}}`), nil
|
|
case strings.Contains(query, "issueCreate"):
|
|
return fakeHTTPResponse(http.StatusOK, `{"data":{"issueCreate":{"issue":{"id":"issue-1","title":"Bug in login flow","identifier":"ENG-123"}}}}`), nil
|
|
case strings.Contains(query, "attachmentLinkURL"):
|
|
return fakeHTTPResponse(http.StatusOK, `{"data":{"attachmentLinkURL":{"attachment":{"id":"attachment-1"}}}}`), nil
|
|
case strings.Contains(query, "attachmentDelete"):
|
|
return fakeHTTPResponse(http.StatusOK, `{"data":{"attachmentDelete":{"success":true}}}`), nil
|
|
case strings.Contains(query, "searchIssues"):
|
|
return fakeHTTPResponse(http.StatusOK, `{"data":{"searchIssues":{"nodes":[{"id":"issue-1","title":"Sample Issue","identifier":"ENG-123"}]}}}`), nil
|
|
case strings.Contains(query, "attachmentsForURL"):
|
|
return fakeHTTPResponse(http.StatusOK, `{"data":{"attachmentsForURL":{"nodes":[{"id":"attachment-1","title":"Sample Issue","issue":{"id":"issue-1","identifier":"ENG-123"}}]}}}`), nil
|
|
default:
|
|
return fakeHTTPResponse(http.StatusUnprocessableEntity, `{"errors":[{"message":"unknown query"}]}`), nil
|
|
}
|
|
})}
|
|
}
|
|
|
|
func fakeHTTPResponse(status int, body string) *http.Response {
|
|
return &http.Response{
|
|
StatusCode: status,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(bytes.NewBufferString(body)),
|
|
}
|
|
}
|
|
|
|
func seedNotionHook(db *gorm.DB, t *testing.T, accountID uint) uint {
|
|
t.Helper()
|
|
settings := model.NotionSettings{
|
|
DatabaseID: "db-1",
|
|
AccessToken: "notion_token_123",
|
|
}
|
|
settingsJSON, err := json.Marshal(settings)
|
|
require.NoError(t, err, "failed to marshal Notion settings")
|
|
|
|
hook := &model.IntegrationHook{
|
|
AccountID: accountID,
|
|
HookType: model.HookTypeNotion,
|
|
Status: model.HookStatusActive,
|
|
AccessToken: "notion_token_123",
|
|
Settings: settingsJSON,
|
|
}
|
|
require.NoError(t, db.Create(hook).Error, "failed to seed Notion hook")
|
|
return hook.ID
|
|
}
|
|
|
|
func setupLinearService(t *testing.T) (*LinearIntegrationService, *gorm.DB) {
|
|
t.Helper()
|
|
db := setupLinearNotionTestDB(t)
|
|
hookRepo := repository.NewIntegrationHookRepo(db)
|
|
svc := NewLinearIntegrationService(hookRepo)
|
|
svc.client = &linearAPIClient{graphqlURL: "https://linear.example.test/graphql", revokeURL: "https://linear.example.test/oauth/revoke", httpClient: fakeLinearHTTPClient(t)}
|
|
return svc, db
|
|
}
|
|
|
|
func setupNotionService(t *testing.T) (*NotionIntegrationService, *gorm.DB) {
|
|
t.Helper()
|
|
db := setupLinearNotionTestDB(t)
|
|
hookRepo := repository.NewIntegrationHookRepo(db)
|
|
svc := NewNotionIntegrationService(hookRepo)
|
|
return svc, db
|
|
}
|
|
|
|
// ========================================
|
|
// LinearIntegrationService tests
|
|
// ========================================
|
|
|
|
func TestLinearIntegrationService_Delete_Success(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
seedLinearHook(db, t, accountID)
|
|
|
|
err := svc.Delete(context.Background(), accountID)
|
|
assert.NoError(t, err)
|
|
|
|
// Verify the hook is gone
|
|
var count int64
|
|
db.Model(&model.IntegrationHook{}).Where("account_id = ? AND hook_type = ?", accountID, model.HookTypeLinear).Count(&count)
|
|
assert.Equal(t, int64(0), count)
|
|
}
|
|
|
|
func TestLinearIntegrationService_Delete_NotFound(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
// No Linear hook seeded
|
|
|
|
err := svc.Delete(context.Background(), accountID)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "Linear integration not found")
|
|
}
|
|
|
|
func TestLinearIntegrationService_GetTeams_Success(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
seedLinearHook(db, t, accountID)
|
|
|
|
teams, err := svc.GetTeams(context.Background(), accountID)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, teams, 1)
|
|
assert.Equal(t, "team-1", teams[0]["id"])
|
|
assert.Equal(t, "Engineering", teams[0]["name"])
|
|
}
|
|
|
|
func TestLinearIntegrationService_GetTeams_NotFound(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
// No Linear hook seeded
|
|
|
|
teams, err := svc.GetTeams(context.Background(), accountID)
|
|
assert.Error(t, err)
|
|
assert.Nil(t, teams)
|
|
assert.Contains(t, err.Error(), "Linear integration not found")
|
|
}
|
|
|
|
func TestLinearIntegrationService_GetTeamEntities_Success(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
seedLinearHook(db, t, accountID)
|
|
|
|
entities, err := svc.GetTeamEntities(context.Background(), accountID, "team-1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "User One", entities["users"].([]map[string]interface{})[0]["name"])
|
|
assert.Equal(t, "Project One", entities["projects"].([]map[string]interface{})[0]["name"])
|
|
assert.Equal(t, "Started", entities["states"].([]map[string]interface{})[0]["name"])
|
|
assert.Equal(t, "Bug", entities["labels"].([]map[string]interface{})[0]["name"])
|
|
}
|
|
|
|
func TestLinearIntegrationService_CreateIssue_Success(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
seedLinearHook(db, t, accountID)
|
|
conversation := seedLinearConversation(db, t, accountID)
|
|
user := seedLinearUser(db, t, accountID)
|
|
|
|
req := CreateIssueRequest{
|
|
Title: "Bug in login flow",
|
|
Description: "Users cannot log in after password reset",
|
|
TeamID: "team-1",
|
|
ConversationID: *conversation.DisplayID,
|
|
}
|
|
|
|
result, err := svc.CreateIssue(context.Background(), accountID, req, user.ID)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.Equal(t, "Bug in login flow", result["title"])
|
|
assert.Equal(t, "ENG-123", result["identifier"])
|
|
|
|
var message model.Message
|
|
require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&message).Error)
|
|
assert.Equal(t, "Linear issue ENG-123 was created by Agent Smith", message.Content)
|
|
}
|
|
|
|
func TestLinearIntegrationService_CreateIssue_NotFound(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
conversation := seedLinearConversation(db, t, accountID)
|
|
// No Linear hook seeded
|
|
|
|
req := CreateIssueRequest{
|
|
Title: "Some issue",
|
|
TeamID: "team-1",
|
|
ConversationID: *conversation.DisplayID,
|
|
}
|
|
|
|
result, err := svc.CreateIssue(context.Background(), accountID, req)
|
|
assert.Error(t, err)
|
|
assert.Nil(t, result)
|
|
assert.Contains(t, err.Error(), "Linear integration not found")
|
|
}
|
|
|
|
func TestLinearIntegrationService_LinkIssue_Success(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
seedLinearHook(db, t, accountID)
|
|
conversation := seedLinearConversation(db, t, accountID)
|
|
user := seedLinearUser(db, t, accountID)
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
|
|
|
req := LinkIssueRequest{
|
|
IssueID: "LIN-42",
|
|
ConversationID: *conversation.DisplayID,
|
|
Title: "Sample Issue",
|
|
}
|
|
|
|
result, err := svc.LinkIssue(context.Background(), accountID, req, user.ID)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.Equal(t, "LIN-42", result["id"])
|
|
assert.Equal(t, "attachment-1", result["link_id"])
|
|
assert.Equal(t, "https://app.example.test/app/accounts/1/conversations/42", result["link"])
|
|
|
|
var message model.Message
|
|
require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&message).Error)
|
|
assert.Equal(t, "Linear issue LIN-42 was linked by Agent Smith", message.Content)
|
|
}
|
|
|
|
func TestLinearIntegrationService_LinkIssue_NotFound(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
conversation := seedLinearConversation(db, t, accountID)
|
|
// No Linear hook seeded
|
|
|
|
req := LinkIssueRequest{
|
|
IssueID: "LIN-42",
|
|
ConversationID: *conversation.DisplayID,
|
|
}
|
|
|
|
result, err := svc.LinkIssue(context.Background(), accountID, req)
|
|
assert.Error(t, err)
|
|
assert.Nil(t, result)
|
|
assert.Contains(t, err.Error(), "Linear integration not found")
|
|
}
|
|
|
|
func TestLinearIntegrationService_UnlinkIssue_Success(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
seedLinearHook(db, t, accountID)
|
|
conversation := seedLinearConversation(db, t, accountID)
|
|
user := seedLinearUser(db, t, accountID)
|
|
|
|
result, err := svc.UnlinkIssue(context.Background(), accountID, UnlinkIssueRequest{IssueID: "ENG-123", LinkID: "attachment-1", ConversationID: *conversation.DisplayID}, user.ID)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "attachment-1", result["link_id"])
|
|
|
|
var message model.Message
|
|
require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&message).Error)
|
|
assert.Equal(t, "Linear issue ENG-123 was unlinked by Agent Smith", message.Content)
|
|
}
|
|
|
|
func TestLinearIntegrationService_SearchIssue_Success(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
seedLinearHook(db, t, accountID)
|
|
|
|
issues, err := svc.SearchIssue(context.Background(), accountID, "Sample")
|
|
assert.NoError(t, err)
|
|
assert.Len(t, issues, 1)
|
|
assert.Equal(t, "Sample Issue", issues[0]["title"])
|
|
}
|
|
|
|
func TestLinearIntegrationService_GetLinkedIssues_UsesConversationDisplayID(t *testing.T) {
|
|
svc, db := setupLinearService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
seedLinearHook(db, t, accountID)
|
|
conversation := seedLinearConversation(db, t, accountID)
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
|
|
|
issues, err := svc.GetLinkedIssues(context.Background(), accountID, *conversation.DisplayID)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, issues, 1)
|
|
assert.Equal(t, "Sample Issue", issues[0]["title"])
|
|
}
|
|
|
|
// ========================================
|
|
// NotionIntegrationService tests
|
|
// ========================================
|
|
|
|
func TestNotionIntegrationService_BuildAuthorizationURL(t *testing.T) {
|
|
t.Setenv("NOTION_CLIENT_ID", "notion-client")
|
|
t.Setenv("NOTION_CLIENT_SECRET", "notion-secret")
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
|
svc, _ := setupNotionService(t)
|
|
|
|
resp, err := svc.BuildAuthorizationURL(42)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
assert.True(t, resp.Success)
|
|
|
|
parsed, err := url.Parse(resp.URL)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https", parsed.Scheme)
|
|
assert.Equal(t, "api.notion.com", parsed.Host)
|
|
assert.Equal(t, "/v1/oauth/authorize", parsed.Path)
|
|
query := parsed.Query()
|
|
assert.Equal(t, "notion-client", query.Get("client_id"))
|
|
assert.Equal(t, "code", query.Get("response_type"))
|
|
assert.Equal(t, "user", query.Get("owner"))
|
|
assert.Equal(t, "https://app.example.test/notion/callback", query.Get("redirect_uri"))
|
|
|
|
claims := jwt.MapClaims{}
|
|
token, err := jwt.ParseWithClaims(query.Get("state"), claims, func(token *jwt.Token) (any, error) {
|
|
return []byte("notion-secret"), nil
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, token.Valid)
|
|
assert.Equal(t, float64(42), claims["sub"])
|
|
}
|
|
|
|
func TestNotionIntegrationService_BuildAuthorizationURL_NotConfigured(t *testing.T) {
|
|
t.Setenv("NOTION_CLIENT_ID", "")
|
|
t.Setenv("NOTION_CLIENT_SECRET", "")
|
|
svc, _ := setupNotionService(t)
|
|
|
|
resp, err := svc.BuildAuthorizationURL(42)
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "Notion OAuth is not configured")
|
|
}
|
|
|
|
func TestNotionIntegrationService_Delete_Success(t *testing.T) {
|
|
svc, db := setupNotionService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
seedNotionHook(db, t, accountID)
|
|
|
|
err := svc.Delete(context.Background(), accountID)
|
|
assert.NoError(t, err)
|
|
|
|
// Verify the hook is gone
|
|
var count int64
|
|
db.Model(&model.IntegrationHook{}).Where("account_id = ? AND hook_type = ?", accountID, model.HookTypeNotion).Count(&count)
|
|
assert.Equal(t, int64(0), count)
|
|
}
|
|
|
|
func TestNotionIntegrationService_Delete_FindsCallbackHookByAppID(t *testing.T) {
|
|
svc, db := setupNotionService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
hook := &model.IntegrationHook{
|
|
AccountID: accountID,
|
|
AppID: "notion",
|
|
HookType: model.HookTypeWebhook,
|
|
Status: model.HookStatusActive,
|
|
AccessToken: "callback_notion_token",
|
|
}
|
|
require.NoError(t, db.Create(hook).Error)
|
|
|
|
err := svc.Delete(context.Background(), accountID)
|
|
assert.NoError(t, err)
|
|
|
|
var count int64
|
|
db.Model(&model.IntegrationHook{}).Where("account_id = ? AND app_id = ?", accountID, "notion").Count(&count)
|
|
assert.Equal(t, int64(0), count)
|
|
}
|
|
|
|
func TestNotionIntegrationService_Delete_NotFound(t *testing.T) {
|
|
svc, db := setupNotionService(t)
|
|
accountID := seedLinearNotionAccount(db, t)
|
|
// No Notion hook seeded
|
|
|
|
err := svc.Delete(context.Background(), accountID)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "Notion integration not found")
|
|
}
|