package service import ( "bytes" "context" "fmt" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/datatypes" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" ) type mockContactSearchReader struct { results []search.SearchResult total int64 filter *search.SearchFilter query string } func (m *mockContactSearchReader) SearchContacts(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]search.SearchResult, int64, error) { m.query = query m.filter = filter return m.results, m.total, nil } type fakeContactExportMailer struct { called bool toEmail string fileURL string subject string } func (m *fakeContactExportMailer) SendContactExportComplete(ctx context.Context, account *model.Account, user *model.User, export *model.ContactExport) error { m.called = true m.toEmail = user.Email m.fileURL = export.FileURL m.subject = contactExportCompleteSubject return nil } // ========== ListActive ========== func TestContactService_ListActive_ReturnsContactsWithActivity(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) // Create contacts: one with last_activity_at set, one without now := time.Now().Unix() activeContact := &model.Contact{ AccountID: account.ID, Name: "Active Contact", Email: "active@test.com", LastActivityAt: &now, } inactiveContact := &model.Contact{ AccountID: account.ID, Name: "Inactive Contact", Email: "inactive@test.com", // LastActivityAt is nil } require.NoError(t, db.Create(activeContact).Error) require.NoError(t, db.Create(inactiveContact).Error) contacts, total, err := svc.ListActive(context.Background(), account.ID, 0, 10, "") require.NoError(t, err) assert.Equal(t, int64(1), total) assert.Len(t, contacts, 1) assert.Equal(t, "Active Contact", contacts[0].Name) } func TestContactService_ListActive_EmptyWhenNoActiveContacts(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) // Create a contact without last_activity_at contact := &model.Contact{ AccountID: account.ID, Name: "No Activity Contact", Email: "noact@test.com", } require.NoError(t, db.Create(contact).Error) contacts, total, err := svc.ListActive(context.Background(), account.ID, 0, 10, "") require.NoError(t, err) assert.Equal(t, int64(0), total) assert.Len(t, contacts, 0) } func TestContactService_ListActive_EmptyWhenNoContacts(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) contacts, total, err := svc.ListActive(context.Background(), account.ID, 0, 10, "") require.NoError(t, err) assert.Equal(t, int64(0), total) assert.Len(t, contacts, 0) } func TestContactService_Search_UsesSearchReader(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) contact := &model.Contact{AccountID: account.ID, Name: "Meili Contact", Email: "meili@example.com"} rejected := &model.Contact{AccountID: account.ID, Name: "DB Contact", Email: "db@example.com"} require.NoError(t, db.Create(contact).Error) require.NoError(t, db.Create(rejected).Error) reader := &mockContactSearchReader{ results: []search.SearchResult{{Type: search.ResultTypeContact, ID: contact.ID, AccountID: account.ID}}, total: 1, } svc.SetSearchReader(reader) contacts, total, err := svc.Search(context.Background(), account.ID, "meili", 0, 10, "", search.SearchModeILike) require.NoError(t, err) assert.Equal(t, int64(1), total) require.Len(t, contacts, 1) assert.Equal(t, contact.ID, contacts[0].ID) assert.Equal(t, "meili", reader.query) require.NotNil(t, reader.filter) assert.Equal(t, []search.SearchResultType{search.ResultTypeContact}, reader.filter.Types) assert.Equal(t, 1, reader.filter.Page) assert.Equal(t, 10, reader.filter.PerPage) } func TestContactService_ListActive_Pagination(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) now := time.Now().Unix() // Create 3 active contacts for i := 0; i < 3; i++ { c := &model.Contact{ AccountID: account.ID, Name: fmt.Sprintf("Contact %c", 'A'+i), Email: fmt.Sprintf("page%c@test.com", 'A'+i), LastActivityAt: &now, } require.NoError(t, db.Create(c).Error) } // Get first page with limit=2 contacts, total, err := svc.ListActive(context.Background(), account.ID, 0, 2, "") require.NoError(t, err) assert.Equal(t, int64(3), total) assert.Len(t, contacts, 2) // Get second page contacts2, total2, err := svc.ListActive(context.Background(), account.ID, 2, 2, "") require.NoError(t, err) assert.Equal(t, int64(3), total2) assert.Len(t, contacts2, 1) } // ========== ExportCSV ========== func TestContactService_ExportCSV_WritesCorrectHeadersAndRows(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) // Create a contact contact := &model.Contact{ AccountID: account.ID, Name: "Export Contact", Email: "export@test.com", PhoneNumber: "+1234567890", Identifier: "id123", CountryCode: "US", Location: "New York", ContactType: "visitor", Blocked: false, } require.NoError(t, db.Create(contact).Error) var buf bytes.Buffer err := svc.ExportCSV(context.Background(), account.ID, &buf) require.NoError(t, err) csvOutput := strings.TrimPrefix(buf.String(), "\ufeff") lines := strings.Split(csvOutput, "\n") // Verify header assert.Equal(t, "id,name,email,phone_number,labels", lines[0]) // Verify data row contains the contact info dataLine := lines[1] assert.Contains(t, dataLine, "Export Contact") assert.Contains(t, dataLine, "export@test.com") assert.Contains(t, dataLine, "+1234567890") } func TestContactService_ExportCSV_EmptyAccount(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) var buf bytes.Buffer err := svc.ExportCSV(context.Background(), account.ID, &buf) require.NoError(t, err) csvOutput := strings.TrimPrefix(buf.String(), "\ufeff") lines := strings.Split(csvOutput, "\n") // Should have header only (plus trailing empty line from csv writer) assert.Equal(t, "id,name,email,phone_number,labels", lines[0]) // No data rows assert.Empty(t, strings.TrimSpace(lines[1])) } func TestContactService_ExportCSV_MultipleContacts(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) for i := 0; i < 3; i++ { c := &model.Contact{ AccountID: account.ID, Name: fmt.Sprintf("Multi Contact %c", 'A'+i), Email: fmt.Sprintf("multi%c@test.com", 'A'+i), } require.NoError(t, db.Create(c).Error) } var buf bytes.Buffer err := svc.ExportCSV(context.Background(), account.ID, &buf) require.NoError(t, err) csvOutput := buf.String() lines := strings.Split(strings.TrimSpace(csvOutput), "\n") // Header + 3 data rows assert.Len(t, lines, 4) } func TestContactService_ExportContacts_PersistsArtifactAndNotification(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) user := createTestUser(t, db, account.ID) mailer := &fakeContactExportMailer{} svc.SetContactExportMailer(mailer) contact := &model.Contact{AccountID: account.ID, Name: "Export Alice", Email: "alice@example.com", PhoneNumber: "+111"} require.NoError(t, db.Create(contact).Error) tag := &model.Tag{AccountID: account.ID, Name: "vip"} require.NoError(t, db.Create(tag).Error) require.NoError(t, db.Create(&model.ContactLabel{AccountID: account.ID, ContactID: contact.ID, TagID: tag.ID}).Error) export, err := svc.ExportContacts(context.Background(), account.ID, user.ID, ContactExportRequest{}) require.NoError(t, err) assert.Equal(t, string(model.DataImportStatusCompleted), export.Status) assert.Equal(t, 1, export.RowCount) assert.Contains(t, export.FileName, "contacts.csv") assert.Contains(t, export.FileURL, "/contacts/export/") assert.Contains(t, string(export.CSVData), "id,name,email,phone_number,labels") assert.Contains(t, string(export.CSVData), "alice@example.com") assert.Contains(t, string(export.CSVData), "vip") var notification model.Notification require.NoError(t, db.Where("user_id = ? AND notification_type = ?", user.ID, "contacts_export_complete").First(¬ification).Error) assert.Equal(t, "ContactExport", notification.PrimaryActorType) assert.Equal(t, export.ID, notification.PrimaryActorID) assert.True(t, notification.EmailEnabled) assert.True(t, mailer.called) assert.Equal(t, user.Email, mailer.toEmail) assert.Equal(t, export.FileURL, mailer.fileURL) assert.Equal(t, contactExportCompleteSubject, mailer.subject) } func TestContactService_ExportContacts_FiltersByLabelAndColumns(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) keep := &model.Contact{AccountID: account.ID, Name: "Keep", Email: "keep@example.com"} drop := &model.Contact{AccountID: account.ID, Name: "Drop", Email: "drop@example.com"} require.NoError(t, db.Create(keep).Error) require.NoError(t, db.Create(drop).Error) tag := &model.Tag{AccountID: account.ID, Name: "vip"} require.NoError(t, db.Create(tag).Error) require.NoError(t, db.Create(&model.ContactLabel{AccountID: account.ID, ContactID: keep.ID, TagID: tag.ID}).Error) csvData, rowCount, err := svc.GenerateContactExportCSV(context.Background(), account.ID, ContactExportRequest{ ColumnNames: []string{"email", "labels", "bogus", "email"}, Label: "vip", }) require.NoError(t, err) assert.Equal(t, 1, rowCount) output := strings.TrimPrefix(string(csvData), "\ufeff") assert.Contains(t, output, "email,labels") assert.Contains(t, output, "keep@example.com,vip") assert.NotContains(t, output, "drop@example.com") assert.NotContains(t, output, "bogus") } // ========== ImportCSV ========== func TestContactService_ImportCSV_ImportsValidRows(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) csvData := "name,email,phone_number\nAlice,alice@test.com,+111\nBob,bob@test.com,+222\n" result, err := svc.ImportCSV(context.Background(), account.ID, strings.NewReader(csvData)) require.NoError(t, err) assert.Equal(t, 2, result.Imported) assert.Equal(t, 0, result.Skipped) assert.Equal(t, 0, result.Failed) // Verify contacts were created var count int64 db.Model(&model.Contact{}).Where("account_id = ?", account.ID).Count(&count) assert.Equal(t, int64(2), count) } func TestContactService_ImportCSV_AllowsRowsWithoutNameWhenIdentityPresent(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) csvData := "name,email\nAlice,alice@test.com\n,bob@test.com\n" result, err := svc.ImportCSV(context.Background(), account.ID, strings.NewReader(csvData)) require.NoError(t, err) assert.Equal(t, 2, result.Imported) assert.Equal(t, 0, result.Skipped) assert.Equal(t, 0, result.Failed) } func TestContactService_ImportCSV_MergesDuplicateEmail(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) // Pre-create a contact with the same email existing := &model.Contact{ AccountID: account.ID, Name: "Existing", Email: "dup@test.com", } require.NoError(t, db.Create(existing).Error) csvData := "name,email\nNewDup,dup@test.com\nUnique,unique@test.com\n" result, err := svc.ImportCSV(context.Background(), account.ID, strings.NewReader(csvData)) require.NoError(t, err) assert.Equal(t, 2, result.Imported) assert.Equal(t, 0, result.Skipped) assert.Equal(t, 0, result.Failed) require.NoError(t, db.First(existing, existing.ID).Error) assert.Equal(t, "NewDup", existing.Name) var count int64 db.Model(&model.Contact{}).Where("account_id = ?", account.ID).Count(&count) assert.Equal(t, int64(2), count) } func TestContactService_ImportContacts_CreatesCompletedDataImport(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) userID := uint(42) require.NoError(t, db.Create(&model.Tag{AccountID: account.ID, Name: "vip"}).Error) csvData := "name,email,labels\nAlice,alice@test.com,vip\n" dataImport, err := svc.ImportContacts(context.Background(), account.ID, userID, strings.NewReader(csvData)) require.NoError(t, err) assert.Equal(t, "contacts", dataImport.DataType) assert.Equal(t, string(model.DataImportStatusCompleted), dataImport.Status) assert.Equal(t, 1, dataImport.TotalRecords) assert.Equal(t, 1, dataImport.ProcessedRecords) assert.NotNil(t, dataImport.UserID) assert.Equal(t, userID, *dataImport.UserID) var labelCount int64 db.Model(&model.ContactLabel{}).Where("account_id = ?", account.ID).Count(&labelCount) assert.Equal(t, int64(1), labelCount) } func TestContactService_ImportContacts_RejectsUnknownLabels(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) require.NoError(t, db.Create(&model.Tag{AccountID: account.ID, Name: "vip"}).Error) csvData := "name,email,labels\nAlice,alice@test.com,vip\nBob,bob@test.com,unknown\n" dataImport, err := svc.ImportContacts(context.Background(), account.ID, 0, strings.NewReader(csvData)) require.NoError(t, err) assert.Equal(t, string(model.DataImportStatusCompleted), dataImport.Status) assert.Equal(t, 2, dataImport.TotalRecords) assert.Equal(t, 1, dataImport.ProcessedRecords) assert.Equal(t, 1, dataImport.FailedRecords) var alice model.Contact require.NoError(t, db.Where("account_id = ? AND email = ?", account.ID, "alice@test.com").First(&alice).Error) var bobCount int64 db.Model(&model.Contact{}).Where("account_id = ? AND email = ?", account.ID, "bob@test.com").Count(&bobCount) assert.Equal(t, int64(0), bobCount) var tagCount int64 db.Model(&model.Tag{}).Where("account_id = ? AND name = ?", account.ID, "unknown").Count(&tagCount) assert.Equal(t, int64(0), tagCount) } func TestContactService_ImportCSV_FailsOnBadCSV(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) // Empty input should fail on header read result, err := svc.ImportCSV(context.Background(), account.ID, strings.NewReader("")) assert.Error(t, err) assert.Nil(t, result) } func TestContactService_ImportCSV_AllColumns(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) csvData := "name,email,phone_number,identifier,country_code,location,contact_type\nFull,full@test.com,+999,fid001,DE,Berlin,visitor\n" result, err := svc.ImportCSV(context.Background(), account.ID, strings.NewReader(csvData)) require.NoError(t, err) assert.Equal(t, 1, result.Imported) // Verify all fields were imported var contact model.Contact require.NoError(t, db.Where("account_id = ? AND email = ?", account.ID, "full@test.com").First(&contact).Error) assert.Equal(t, "Full", contact.Name) assert.Equal(t, "+999", contact.PhoneNumber) assert.Equal(t, "fid001", contact.Identifier) assert.Equal(t, "DE", contact.CountryCode) assert.Equal(t, "Berlin", contact.Location) assert.Equal(t, "lead", contact.ContactType) // visitor upgraded to lead by BeforeSave (has email+phone) } // ========== DeleteCustomAttributes ========== func TestContactService_DeleteCustomAttributes_ClearsCustomAttributes(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) // Create a contact with custom attributes contact := &model.Contact{ AccountID: account.ID, Name: "Custom Attr Contact", Email: "custom@test.com", CustomAttributes: datatypes.JSON(`{"key1":"val1","key2":"val2"}`), } require.NoError(t, db.Create(contact).Error) err := svc.DeleteCustomAttributes(context.Background(), account.ID, contact.ID) require.NoError(t, err) // Verify custom_attributes is now empty var updated model.Contact require.NoError(t, db.First(&updated, contact.ID).Error) assert.Equal(t, datatypes.JSON("{}"), updated.CustomAttributes) } func TestContactService_DeleteCustomAttributes_ContactNotFound(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) err := svc.DeleteCustomAttributes(context.Background(), account.ID, 99999) assert.Error(t, err) assert.Equal(t, "contact not found", err.Error()) } func TestContactService_DeleteCustomAttributes_WrongAccount(t *testing.T) { db, _, svc := setupContactService(t) account1 := createTestAccountWithName(t, db, "Account 1") account2 := createTestAccountWithName(t, db, "Account 2") // Contact belongs to account1 contact := &model.Contact{ AccountID: account1.ID, Name: "Scoped Contact", Email: "scoped@test.com", CustomAttributes: datatypes.JSON(`{"key":"val"}`), } require.NoError(t, db.Create(contact).Error) // Try to delete custom attrs using account2 — should fail err := svc.DeleteCustomAttributes(context.Background(), account2.ID, contact.ID) assert.Error(t, err) assert.Equal(t, "contact not found", err.Error()) } // ========== GetContactableInboxes ========== func TestContactService_GetContactableInboxes_ReturnsExistingInboxes(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) contact := createTestContact(t, db, account.ID) inbox := createTestInbox(t, db, account.ID, "web_widget") // Create a contact_inbox association ci := &model.ContactInbox{ ContactID: contact.ID, InboxID: inbox.ID, SourceID: "src_test", HMACToken: "hmac_test", PubsubToken: "pubsub_test", } require.NoError(t, db.Create(ci).Error) result, err := svc.GetContactableInboxes(context.Background(), account.ID, contact.ID) require.NoError(t, err) assert.Len(t, result, 1) assert.Equal(t, inbox.ID, result[0].Inbox.ID) assert.Equal(t, "web_widget", result[0].Inbox.ChannelType) assert.Equal(t, ci.ID, result[0].ContactInbox.ID) assert.Equal(t, "src_test", result[0].SourceID) } func TestContactService_GetContactableInboxes_MultipleInboxes(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) contact := createTestContact(t, db, account.ID) inbox1 := createTestInbox(t, db, account.ID, "web_widget") inbox2 := createTestInbox(t, db, account.ID, "api") // Create two contact_inbox associations ci1 := &model.ContactInbox{ ContactID: contact.ID, InboxID: inbox1.ID, SourceID: "src_web", HMACToken: "hmac1", PubsubToken: "pubsub1", } ci2 := &model.ContactInbox{ ContactID: contact.ID, InboxID: inbox2.ID, SourceID: "src_api", HMACToken: "hmac2", PubsubToken: "pubsub2", } require.NoError(t, db.Create(ci1).Error) require.NoError(t, db.Create(ci2).Error) result, err := svc.GetContactableInboxes(context.Background(), account.ID, contact.ID) require.NoError(t, err) assert.Len(t, result, 2) // Check both inboxes are present inboxTypes := map[string]bool{} for _, r := range result { inboxTypes[r.Inbox.ChannelType] = true } assert.True(t, inboxTypes["web_widget"]) assert.True(t, inboxTypes["api"]) } func TestContactService_GetContactableInboxes_ContactNotFound(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) result, err := svc.GetContactableInboxes(context.Background(), account.ID, 99999) assert.Error(t, err) assert.Equal(t, "contact not found", err.Error()) assert.Nil(t, result) } func TestContactService_GetContactableInboxes_WrongAccount(t *testing.T) { db, _, svc := setupContactService(t) account1 := createTestAccountWithName(t, db, "Account 1") account2 := createTestAccountWithName(t, db, "Account 2") contact := createTestContact(t, db, account1.ID) // Try with wrong account result, err := svc.GetContactableInboxes(context.Background(), account2.ID, contact.ID) assert.Error(t, err) assert.Equal(t, "contact not found", err.Error()) assert.Nil(t, result) } func TestContactService_GetContactableInboxes_NoInboxes(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) contact := createTestContact(t, db, account.ID) // No contact_inbox associations created result, err := svc.GetContactableInboxes(context.Background(), account.ID, contact.ID) require.NoError(t, err) assert.Len(t, result, 0) }