package service import ( "context" "testing" "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/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) // setupAssignmentTestDB creates a SQLite in-memory DB with all models needed // for assignment tests: Account, User, AccountUser, Inbox, InboxMember, // Contact, Conversation, Team, TeamMember. func setupAssignmentTestDB(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) models := []interface{}{ &model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.InboxMember{}, &model.Contact{}, &model.Conversation{}, &model.Team{}, &model.TeamMember{}, &model.AgentCapacityPolicy{}, &model.InboxCapacityLimit{}, } require.NoError(t, db.AutoMigrate(models...)) return db } // setupAssignmentService creates a ConversationService with all repos needed // for deep assignment logic testing. func setupAssignmentService(t *testing.T) (*ConversationService, *gorm.DB) { t.Helper() db := setupAssignmentTestDB(t) convRepo := repository.NewConversationRepo(db) msgRepo := repository.NewMessageRepo(db) dispatcher := channel.NewDispatcher() inboxMemberRepo := repository.NewInboxMemberRepo(db) inboxMemberSvc := NewInboxMemberService(inboxMemberRepo) accountUserRepo := repository.NewAccountUserRepo(db) teamRepo := repository.NewTeamRepo(db) teamMemberRepo := repository.NewTeamMemberRepo(db) svc := NewConversationService(convRepo, msgRepo, dispatcher, inboxMemberSvc, accountUserRepo, teamRepo, teamMemberRepo) return svc, db } // ========== Helper functions for creating test data ========== func createAssignmentAccount(t *testing.T, db *gorm.DB) *model.Account { t.Helper() acc := &model.Account{Name: "Test Account"} require.NoError(t, db.Create(acc).Error) return acc } func createAssignmentUser(t *testing.T, db *gorm.DB, name, email string) *model.User { t.Helper() u := &model.User{Name: name, Email: email, AccountID: 1, Password: "hashed"} require.NoError(t, db.Create(u).Error) return u } func createAssignmentAccountUser(t *testing.T, db *gorm.DB, accountID, userID uint, role, availability string) *model.AccountUser { t.Helper() au := &model.AccountUser{AccountID: accountID, UserID: userID, Role: role, Availability: availability} require.NoError(t, db.Create(au).Error) return au } func createAssignmentInbox(t *testing.T, db *gorm.DB, accountID uint) *model.Inbox { t.Helper() inbox := &model.Inbox{AccountID: accountID, Name: "Test Inbox", ChannelType: string(model.InboxChannelTypeWebWidget)} require.NoError(t, db.Create(inbox).Error) return inbox } func createAssignmentInboxMember(t *testing.T, db *gorm.DB, inboxID, userID uint) { t.Helper() im := &model.InboxMember{InboxID: inboxID, UserID: userID} require.NoError(t, db.Create(im).Error) } func createAssignmentContact(t *testing.T, db *gorm.DB, accountID uint) *model.Contact { t.Helper() c := &model.Contact{AccountID: accountID, Name: "Test Contact"} require.NoError(t, db.Create(c).Error) return c } func createAssignmentConversation(t *testing.T, db *gorm.DB, accountID, inboxID, contactID uint) *model.Conversation { t.Helper() conv := &model.Conversation{ AccountID: accountID, InboxID: inboxID, ContactID: contactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget", } require.NoError(t, db.Create(conv).Error) return conv } func createAssignmentCapacityLimit(t *testing.T, db *gorm.DB, accountID, inboxID, userID uint, conversationLimit int) *model.AgentCapacityPolicy { t.Helper() policy := &model.AgentCapacityPolicy{AccountID: accountID, Name: "Capacity", AssignmentLogic: "round_robin", ExclusionRules: []byte(`{}`)} require.NoError(t, db.Create(policy).Error) require.NoError(t, db.Model(&model.AccountUser{}). Where("account_id = ? AND user_id = ?", accountID, userID). Update("agent_capacity_policy_id", policy.ID).Error) require.NoError(t, db.Create(&model.InboxCapacityLimit{ AgentCapacityPolicyID: policy.ID, InboxID: inboxID, ConversationLimit: conversationLimit, }).Error) return policy } func createAssignmentTeam(t *testing.T, db *gorm.DB, accountID uint, name string, allowAutoAssignment bool) *model.Team { t.Helper() team := &model.Team{AccountID: accountID, Name: name, AllowAutoAssignment: true} // SQLite default:true workaround — create as true first require.NoError(t, db.Create(team).Error) if !allowAutoAssignment { require.NoError(t, db.Model(team).Update("allow_auto_assignment", false).Error) team.AllowAutoAssignment = false } return team } func createAssignmentTeamMember(t *testing.T, db *gorm.DB, teamID, userID uint, availability string) *model.TeamMember { t.Helper() tm := &model.TeamMember{TeamID: teamID, UserID: userID, AvailabilityStatus: availability} require.NoError(t, db.Create(tm).Error) return tm } // ========== AssignAgent Authorization Tests ========== func TestAssignAgent_Authorization_RejectsNonAccountMember(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // Create a user who is NOT an agent/admin in this account outsider := createAssignmentUser(t, db, "Outsider", "outsider@test.com") _, err := svc.AssignAgent(ctx, account.ID, conv.ID, outsider.ID) assert.Error(t, err) assert.Contains(t, err.Error(), "not an agent or administrator") } func TestAssignAgent_Authorization_AcceptsAgent(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // Create an agent who IS a member of this account AND inbox agent := createAssignmentUser(t, db, "Agent1", "agent1@test.com") createAssignmentAccountUser(t, db, account.ID, agent.ID, "agent", "online") createAssignmentInboxMember(t, db, inbox.ID, agent.ID) result, err := svc.AssignAgent(ctx, account.ID, conv.ID, agent.ID) assert.NoError(t, err) assert.Equal(t, agent.ID, *result.AssigneeID) } func TestAssignAgent_Authorization_AcceptsAdministrator(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // Create an admin who IS a member of this account AND inbox admin := createAssignmentUser(t, db, "Admin1", "admin1@test.com") createAssignmentAccountUser(t, db, account.ID, admin.ID, "administrator", "online") createAssignmentInboxMember(t, db, inbox.ID, admin.ID) result, err := svc.AssignAgent(ctx, account.ID, conv.ID, admin.ID) assert.NoError(t, err) assert.Equal(t, admin.ID, *result.AssigneeID) } func TestAssignAgent_RejectsInboxNonMember(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // Create an agent in the account but NOT in the inbox agent := createAssignmentUser(t, db, "NoInboxAgent", "noinbox@test.com") createAssignmentAccountUser(t, db, account.ID, agent.ID, "agent", "online") // Do NOT add to inbox members _, err := svc.AssignAgent(ctx, account.ID, conv.ID, agent.ID) assert.Error(t, err) assert.Contains(t, err.Error(), "not a member of the conversation's inbox") } func TestAssignAgent_RejectsAgentAtInboxCapacity(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) existing := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) agent := createAssignmentUser(t, db, "CapacityAgent", "capacity@test.com") createAssignmentAccountUser(t, db, account.ID, agent.ID, "agent", "online") createAssignmentInboxMember(t, db, inbox.ID, agent.ID) createAssignmentCapacityLimit(t, db, account.ID, inbox.ID, agent.ID, 1) require.NoError(t, db.Model(existing).Update("assignee_id", agent.ID).Error) _, err := svc.AssignAgent(ctx, account.ID, conv.ID, agent.ID) assert.Error(t, err) assert.Contains(t, err.Error(), "reached capacity") } func TestAssignAgent_IgnoresResolvedConversationsForCapacity(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) resolved := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) agent := createAssignmentUser(t, db, "CapacityAgent2", "capacity2@test.com") createAssignmentAccountUser(t, db, account.ID, agent.ID, "agent", "online") createAssignmentInboxMember(t, db, inbox.ID, agent.ID) createAssignmentCapacityLimit(t, db, account.ID, inbox.ID, agent.ID, 1) require.NoError(t, db.Model(resolved).Updates(map[string]any{"assignee_id": agent.ID, "status": model.ConversationStatusResolved}).Error) result, err := svc.AssignAgent(ctx, account.ID, conv.ID, agent.ID) assert.NoError(t, err) require.NotNil(t, result.AssigneeID) assert.Equal(t, agent.ID, *result.AssigneeID) } func TestAssignAgent_Unassign_SetsNilAssigneeID(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // First assign an agent agent := createAssignmentUser(t, db, "Agent1", "agent1@test.com") createAssignmentAccountUser(t, db, account.ID, agent.ID, "agent", "online") createAssignmentInboxMember(t, db, inbox.ID, agent.ID) result, err := svc.AssignAgent(ctx, account.ID, conv.ID, agent.ID) require.NoError(t, err) require.NotNil(t, result.AssigneeID) // Then unassign (assigneeID = 0) result, err = svc.AssignAgent(ctx, account.ID, conv.ID, 0) assert.NoError(t, err) assert.Nil(t, result.AssigneeID) } // ========== AssignTeam Validation Tests ========== func TestAssignTeam_RejectsTeamNotInAccount(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() // Create two accounts account1 := createAssignmentAccount(t, db) account2 := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account1.ID) contact := createAssignmentContact(t, db, account1.ID) conv := createAssignmentConversation(t, db, account1.ID, inbox.ID, contact.ID) // Create a team in account2 (wrong account!) team2 := createAssignmentTeam(t, db, account2.ID, "Wrong Team", true) _, err := svc.AssignTeam(ctx, account1.ID, conv.ID, nil, &team2.ID) assert.Error(t, err) assert.Contains(t, err.Error(), "team not found in this account") } func TestAssignTeam_RejectsTeamWithoutAutoAssignment(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // Create a team with AllowAutoAssignment = false team := createAssignmentTeam(t, db, account.ID, "NoAuto Team", false) // Try to assign team without specifying an agent — should be rejected _, err := svc.AssignTeam(ctx, account.ID, conv.ID, nil, &team.ID) assert.Error(t, err) assert.Contains(t, err.Error(), "does not allow auto-assignment") } func TestAssignTeam_AllowsTeamWithAutoAssignment(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // Create a team with AllowAutoAssignment = true team := createAssignmentTeam(t, db, account.ID, "Auto Team", true) result, err := svc.AssignTeam(ctx, account.ID, conv.ID, nil, &team.ID) assert.NoError(t, err) assert.Equal(t, team.ID, *result.TeamID) } func TestAssignTeam_WithAgentAndTeam(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) team := createAssignmentTeam(t, db, account.ID, "Team1", true) agent := createAssignmentUser(t, db, "Agent1", "agent1@test.com") createAssignmentAccountUser(t, db, account.ID, agent.ID, "agent", "online") createAssignmentInboxMember(t, db, inbox.ID, agent.ID) result, err := svc.AssignTeam(ctx, account.ID, conv.ID, &agent.ID, &team.ID) assert.NoError(t, err) assert.Equal(t, team.ID, *result.TeamID) assert.Equal(t, agent.ID, *result.AssigneeID) } func TestAssignTeam_RejectsAgentNotInAccount(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) team := createAssignmentTeam(t, db, account.ID, "Team1", true) outsideAgent := createAssignmentUser(t, db, "Outside", "outside@test.com") // NOT adding AccountUser for this agent _, err := svc.AssignTeam(ctx, account.ID, conv.ID, &outsideAgent.ID, &team.ID) assert.Error(t, err) assert.Contains(t, err.Error(), "not an agent or administrator") } func TestAssignTeam_Overflow_FallbackToAccountOnlineAgent(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // Create a team team := createAssignmentTeam(t, db, account.ID, "Overflow Team", true) // Add 2 team members — both OFFLINE member1 := createAssignmentUser(t, db, "Member1", "member1@test.com") member2 := createAssignmentUser(t, db, "Member2", "member2@test.com") createAssignmentAccountUser(t, db, account.ID, member1.ID, "agent", "offline") createAssignmentAccountUser(t, db, account.ID, member2.ID, "agent", "offline") createAssignmentTeamMember(t, db, team.ID, member1.ID, "offline") createAssignmentTeamMember(t, db, team.ID, member2.ID, "offline") // Add an account-level online agent (NOT in the team) for overflow fallback fallbackAgent := createAssignmentUser(t, db, "Fallback", "fallback@test.com") createAssignmentAccountUser(t, db, account.ID, fallbackAgent.ID, "agent", "online") createAssignmentInboxMember(t, db, inbox.ID, fallbackAgent.ID) // Assign team without specifying agent — should overflow to fallback result, err := svc.AssignTeam(ctx, account.ID, conv.ID, nil, &team.ID) assert.NoError(t, err) assert.Equal(t, team.ID, *result.TeamID) // Overflow should assign the fallback agent assert.NotNil(t, result.AssigneeID) assert.Equal(t, fallbackAgent.ID, *result.AssigneeID) } func TestAssignTeam_Overflow_NoOnlineAgents_NoFallback(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // Create a team team := createAssignmentTeam(t, db, account.ID, "NoFallback Team", true) // Add team members — all OFFLINE member1 := createAssignmentUser(t, db, "Member1", "member1@test.com") createAssignmentAccountUser(t, db, account.ID, member1.ID, "agent", "offline") createAssignmentTeamMember(t, db, team.ID, member1.ID, "offline") // No online agents in the account at all // Assign team without agent — overflow should NOT assign anyone result, err := svc.AssignTeam(ctx, account.ID, conv.ID, nil, &team.ID) assert.NoError(t, err) assert.Equal(t, team.ID, *result.TeamID) // No overflow agent available, so AssigneeID should remain nil assert.Nil(t, result.AssigneeID) } func TestAssignTeam_OnlineTeamMember_NoOverflow(t *testing.T) { svc, db := setupAssignmentService(t) ctx := context.Background() account := createAssignmentAccount(t, db) inbox := createAssignmentInbox(t, db, account.ID) contact := createAssignmentContact(t, db, account.ID) conv := createAssignmentConversation(t, db, account.ID, inbox.ID, contact.ID) // Create a team with online members team := createAssignmentTeam(t, db, account.ID, "Online Team", true) onlineMember := createAssignmentUser(t, db, "OnlineMember", "online@test.com") createAssignmentAccountUser(t, db, account.ID, onlineMember.ID, "agent", "online") createAssignmentTeamMember(t, db, team.ID, onlineMember.ID, "online") // Assign team without agent — no overflow needed (member is online) result, err := svc.AssignTeam(ctx, account.ID, conv.ID, nil, &team.ID) assert.NoError(t, err) assert.Equal(t, team.ID, *result.TeamID) // No overflow triggered since team has online members assert.Nil(t, result.AssigneeID) } // ========== AccountUserRepo Tests ========== func TestAccountUserRepo_IsAgentOrAdmin(t *testing.T) { db := setupAssignmentTestDB(t) repo := repository.NewAccountUserRepo(db) ctx := context.Background() account := createAssignmentAccount(t, db) user := createAssignmentUser(t, db, "TestUser", "test@test.com") // User not in account yet isMember, err := repo.IsAgentOrAdmin(ctx, account.ID, user.ID) assert.NoError(t, err) assert.False(t, isMember) // Add as agent createAssignmentAccountUser(t, db, account.ID, user.ID, "agent", "online") isMember, err = repo.IsAgentOrAdmin(ctx, account.ID, user.ID) assert.NoError(t, err) assert.True(t, isMember) } func TestAccountUserRepo_IsAdministrator(t *testing.T) { db := setupAssignmentTestDB(t) repo := repository.NewAccountUserRepo(db) ctx := context.Background() account := createAssignmentAccount(t, db) agentUser := createAssignmentUser(t, db, "Agent", "agent@test.com") adminUser := createAssignmentUser(t, db, "Admin", "admin@test.com") createAssignmentAccountUser(t, db, account.ID, agentUser.ID, "agent", "online") createAssignmentAccountUser(t, db, account.ID, adminUser.ID, "administrator", "online") isAdmin, err := repo.IsAdministrator(ctx, account.ID, agentUser.ID) assert.NoError(t, err) assert.False(t, isAdmin) isAdmin, err = repo.IsAdministrator(ctx, account.ID, adminUser.ID) assert.NoError(t, err) assert.True(t, isAdmin) } func TestAccountUserRepo_FindOnlineAgentsByAccount(t *testing.T) { db := setupAssignmentTestDB(t) repo := repository.NewAccountUserRepo(db) ctx := context.Background() account := createAssignmentAccount(t, db) onlineAgent := createAssignmentUser(t, db, "Online1", "online1@test.com") offlineAgent := createAssignmentUser(t, db, "Offline1", "offline1@test.com") createAssignmentAccountUser(t, db, account.ID, onlineAgent.ID, "agent", "online") createAssignmentAccountUser(t, db, account.ID, offlineAgent.ID, "agent", "offline") onlineAgents, err := repo.FindOnlineAgentsByAccount(ctx, account.ID) assert.NoError(t, err) assert.Len(t, onlineAgents, 1) assert.Equal(t, onlineAgent.ID, onlineAgents[0].UserID) } func TestAccountUserRepo_FindByAccountAndUser(t *testing.T) { db := setupAssignmentTestDB(t) repo := repository.NewAccountUserRepo(db) ctx := context.Background() account := createAssignmentAccount(t, db) user := createAssignmentUser(t, db, "TestUser", "test@test.com") // Not in account yet au, err := repo.FindByAccountAndUser(ctx, account.ID, user.ID) assert.NoError(t, err) assert.Nil(t, au) // Add to account createAssignmentAccountUser(t, db, account.ID, user.ID, "agent", "online") au, err = repo.FindByAccountAndUser(ctx, account.ID, user.ID) assert.NoError(t, err) assert.NotNil(t, au) assert.Equal(t, "agent", au.Role) }