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") }