Files
gochat/backend/internal/service/coverage3_test.go
T
Rogeeandrogee 2b182f9956 H-300: wire Captain Skills into Web runtime (#48)
* H-300: wire Captain Skills into Web runtime

* H-300: enforce effective model and conservative skill budget

* H-300: fix CI gosec step

* ci: extend golangci-lint timeout

* fix lint findings across backend

* fix(push): resolve delivery protocol blockers

* test(repository): close SQLite test databases

* test(repository): reuse SQLite schema per package

* H-307: restore backend Go cache in CI

* H-307: prefetch modules before cold lint

* H-307: resolve govulncheck security gate

* H-307: build lint with patched Go toolchain

* H-307: clear remaining security scan findings

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-19 07:08:14 +08:00

2713 lines
98 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/campaign"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// ============================================================
// Helper: DB provider for automation package
// ============================================================
type testDBProvider struct{ db *gorm.DB }
func (p *testDBProvider) DB() *gorm.DB { return p.db }
// mockLLMProvider3 is a mock implementation of llm.Provider for coverage3 tests.
// (mockLLMProvider is already declared in captain_task_service_test.go)
type mockLLMProvider3 struct {
chatResp *llm.ChatResponse
chatErr error
lastReq *llm.ChatRequest
streamErr error
}
func (m *mockLLMProvider3) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
m.lastReq = &req
if m.chatErr != nil {
return nil, m.chatErr
}
if m.chatResp != nil {
return m.chatResp, nil
}
return &llm.ChatResponse{
Choices: []llm.ChatChoice{
{Message: llm.ChatMessage{Role: "assistant", Content: "mock response"}},
},
}, nil
}
func (m *mockLLMProvider3) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
return nil, nil
}
func (m *mockLLMProvider3) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error {
return m.streamErr
}
// ============================================================
// AgentService tests
// ============================================================
func TestAgentService_Create(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
detail, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{
Email: "agent1@example.com",
Name: "Agent One",
Role: "agent",
})
require.NoError(t, err)
assert.NotZero(t, detail.ID)
assert.Equal(t, "Agent One", detail.Name)
assert.Equal(t, "agent", detail.Role)
assert.True(t, detail.IsNewUser)
assert.NotEmpty(t, detail.TemporaryPassword)
}
func TestAgentService_Create_DefaultRoleAndAvailability(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
detail, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{
Email: "agent2@example.com",
})
require.NoError(t, err)
assert.Equal(t, "agent", detail.Role)
assert.Equal(t, "offline", detail.Availability)
}
func TestAgentService_Create_NameFromEmail(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
detail, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{
Email: "john.doe@example.com",
})
require.NoError(t, err)
assert.Equal(t, "john.doe", detail.Name)
}
func TestAgentService_Create_ValidationError(t *testing.T) {
db := newSimpleServiceTestDB(t)
svc := NewAgentService(repository.NewAgentRepo(db), db)
_, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{
Email: "invalid",
})
require.Error(t, err)
}
func TestAgentService_Create_AlreadyMember(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
_, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{
Email: "dup@example.com",
Name: "Dup",
})
require.NoError(t, err)
_, err = svc.Create(context.Background(), 1, 1, CreateAgentRequest{
Email: "dup@example.com",
Name: "Dup",
})
require.Error(t, err)
}
func TestAgentService_Get(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
detail, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{
Email: "get@example.com",
Name: "Get Agent",
})
require.NoError(t, err)
got, err := svc.Get(context.Background(), detail.ID, 1)
require.NoError(t, err)
assert.Equal(t, "Get Agent", got.Name)
}
func TestAgentService_Get_NotFound(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
_, err := svc.Get(context.Background(), 9999, 1)
require.Error(t, err)
}
func TestAgentService_List(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
_, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{Email: "list1@example.com", Name: "List1"})
require.NoError(t, err)
_, err = svc.Create(context.Background(), 1, 1, CreateAgentRequest{Email: "list2@example.com", Name: "List2"})
require.NoError(t, err)
agents, total, err := svc.List(context.Background(), 1, 0, 10)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, agents, 2)
}
func TestAgentService_Update(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
detail, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{Email: "upd@example.com", Name: "Orig"})
require.NoError(t, err)
updated, err := svc.Update(context.Background(), detail.ID, 1, UpdateAgentRequest{
Name: "Updated",
Role: "administrator",
Availability: "online",
})
require.NoError(t, err)
assert.Equal(t, "Updated", updated.Name)
assert.Equal(t, "administrator", updated.Role)
assert.Equal(t, "online", updated.Availability)
}
func TestAgentService_Update_BlankName(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
detail, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{Email: "blank@example.com", Name: "Orig"})
require.NoError(t, err)
req := UpdateAgentRequest{}
require.NoError(t, req.UnmarshalJSON([]byte(`{"name":""}`)))
_, err = svc.Update(context.Background(), detail.ID, 1, req)
require.Error(t, err)
}
func TestAgentService_Delete(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
detail, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{Email: "del@example.com", Name: "Del"})
require.NoError(t, err)
err = svc.Delete(context.Background(), detail.ID, 1)
require.NoError(t, err)
}
func TestAgentService_ResetPassword(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
detail, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{Email: "reset@example.com", Name: "Reset"})
require.NoError(t, err)
pw, err := svc.ResetPassword(context.Background(), detail.ID, 1)
require.NoError(t, err)
assert.NotEmpty(t, pw)
}
func TestAgentService_ResetPassword_NotFound(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
_, err := svc.ResetPassword(context.Background(), 9999, 1)
require.Error(t, err)
}
func TestAgentService_BulkCreate(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
created, err := svc.BulkCreate(context.Background(), 1, 1, BulkCreateAgentRequest{
Emails: []string{"bulk1@example.com", "bulk2@example.com", "invalid-email"},
})
require.NoError(t, err)
assert.Len(t, created, 2)
}
func TestAgentService_AvailableAgentCount_Unlimited(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test", AgentLimit: 0}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
count, err := svc.AvailableAgentCount(context.Background(), 1)
require.NoError(t, err)
assert.Equal(t, -1, count)
}
func TestAgentService_AvailableAgentCount_WithLimit(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test", AgentLimit: 5}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
_, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{Email: "lim@example.com", Name: "Lim"})
require.NoError(t, err)
count, err := svc.AvailableAgentCount(context.Background(), 1)
require.NoError(t, err)
assert.Equal(t, 4, count)
}
func TestAgentService_AvailableAgentCount_AccountNotFound(t *testing.T) {
db := newSimpleServiceTestDB(t)
svc := NewAgentService(repository.NewAgentRepo(db), db)
_, err := svc.AvailableAgentCount(context.Background(), 9999)
require.Error(t, err)
}
func TestAgentService_CanAddAgent(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test", AgentLimit: 5}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
ok, err := svc.CanAddAgent(context.Background(), 1)
require.NoError(t, err)
assert.True(t, ok)
}
func TestAgentService_CanAddAgent_LimitReached(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test", AgentLimit: 1}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
_, err := svc.Create(context.Background(), 1, 1, CreateAgentRequest{Email: "lim1@example.com", Name: "Lim1"})
require.NoError(t, err)
ok, err := svc.CanAddAgent(context.Background(), 1)
require.NoError(t, err)
assert.False(t, ok)
}
func TestAgentService_CanAddAgents(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test", AgentLimit: 5}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
ok, err := svc.CanAddAgents(context.Background(), 1, 3)
require.NoError(t, err)
assert.True(t, ok)
}
func TestAgentService_CanAddAgents_ExceedsLimit(t *testing.T) {
db := newSimpleServiceTestDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test", AgentLimit: 2}).Error)
svc := NewAgentService(repository.NewAgentRepo(db), db)
ok, err := svc.CanAddAgents(context.Background(), 1, 5)
require.NoError(t, err)
assert.False(t, ok)
}
func TestAgentService_DB(t *testing.T) {
db := newSimpleServiceTestDB(t)
svc := NewAgentService(repository.NewAgentRepo(db), db)
assert.Equal(t, db, svc.DB())
}
func TestAgentService_DB_Nil(t *testing.T) {
var svc *AgentService
assert.Nil(t, svc.DB())
}
// ============================================================
// AccountUserService tests
// ============================================================
func newAccountUserServiceDB(t *testing.T) *gorm.DB {
return newSimpleServiceTestDB(t, &model.NotificationSetting{})
}
func TestAccountUserService_AddUserToAccount(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "au@example.com", Password: "x", Role: "agent", Active: true}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
au, err := svc.AddUserToAccount(context.Background(), 1, &CreateAccountUserRequest{
UserID: 1, Role: "agent", InviterID: 2,
})
require.NoError(t, err)
assert.NotZero(t, au.ID)
assert.Equal(t, "agent", au.Role)
assert.Equal(t, "offline", au.Availability)
assert.True(t, au.AutoOffline)
}
func TestAccountUserService_AddUserToAccount_AlreadyExists(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "au@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
_, err := svc.AddUserToAccount(context.Background(), 1, &CreateAccountUserRequest{
UserID: 1, Role: "agent",
})
require.Error(t, err)
}
func TestAccountUserService_AddUserToAccount_AccountNotFound(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "au@example.com", Password: "x", Role: "agent", Active: true}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
_, err := svc.AddUserToAccount(context.Background(), 999, &CreateAccountUserRequest{
UserID: 1, Role: "agent",
})
require.Error(t, err)
}
func TestAccountUserService_AddUserToAccount_UserNotFound(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
_, err := svc.AddUserToAccount(context.Background(), 1, &CreateAccountUserRequest{
UserID: 999, Role: "agent",
})
require.Error(t, err)
}
func TestAccountUserService_RemoveUserFromAccount(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "rm@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
err := svc.RemoveUserFromAccount(context.Background(), 1, 1)
require.NoError(t, err)
}
func TestAccountUserService_RemoveUserFromAccount_NotFound(t *testing.T) {
db := newAccountUserServiceDB(t)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
err := svc.RemoveUserFromAccount(context.Background(), 1, 999)
require.Error(t, err)
}
func TestAccountUserService_UpdateAvailability(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "av@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent", Availability: "offline"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
err := svc.UpdateAvailability(context.Background(), 1, 1, "online")
require.NoError(t, err)
}
func TestAccountUserService_UpdateAvailability_InvalidValue(t *testing.T) {
db := newAccountUserServiceDB(t)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
err := svc.UpdateAvailability(context.Background(), 1, 1, "invalid")
require.Error(t, err)
}
func TestAccountUserService_UpdateRole(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "role@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
err := svc.UpdateRole(context.Background(), 1, 1, "administrator")
require.NoError(t, err)
}
func TestAccountUserService_UpdateRole_InvalidRole(t *testing.T) {
db := newAccountUserServiceDB(t)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
err := svc.UpdateRole(context.Background(), 1, 1, "invalid")
require.Error(t, err)
}
func TestAccountUserService_ListByAccount(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U1", Email: "u1@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U2", Email: "u2@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 2, AccountID: 1, Role: "administrator"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
users, total, err := svc.ListByAccount(context.Background(), 1, 0, 10)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, users, 2)
}
func TestAccountUserService_GetByAccountAndUser(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "get@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
au, err := svc.GetByAccountAndUser(context.Background(), 1, 1)
require.NoError(t, err)
assert.Equal(t, "agent", au.Role)
}
func TestAccountUserService_GetByAccountAndUser_NotFound(t *testing.T) {
db := newAccountUserServiceDB(t)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
_, err := svc.GetByAccountAndUser(context.Background(), 1, 999)
require.Error(t, err)
}
func TestAccountUserService_MarkActive(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "ma@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
err := svc.MarkActive(context.Background(), 1, 1)
require.NoError(t, err)
}
func TestAccountUserService_SetAutoOffline(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "ao@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
err := svc.SetAutoOffline(context.Background(), 1, 1, false)
require.NoError(t, err)
}
func TestAccountUserService_FindOnlineAgents(t *testing.T) {
db := newAccountUserServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "on@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent", Availability: "online"}).Error)
svc := NewAccountUserService(
repository.NewAccountUserRepo(db),
repository.NewNotificationSettingRepo(db),
repository.NewAccountRepo(db),
repository.NewUserRepo(db),
nil,
)
agents, err := svc.FindOnlineAgents(context.Background(), 1)
require.NoError(t, err)
assert.Len(t, agents, 1)
}
// ============================================================
// AnalyticsService CSV methods tests
// ============================================================
func TestAnalyticsService_GetAgentReportCSVRows(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.ReportingEvent{}, &model.ReportingEventsRollup{})
svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db))
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "Agent1", Email: "a1@example.com", Password: "x", Role: "agent", Active: true}).Error)
since := time.Now().AddDate(0, -1, 0)
until := time.Now()
rows, err := svc.GetAgentReportCSVRows(context.Background(), 1, since, until, false)
require.NoError(t, err)
assert.NotEmpty(t, rows)
}
func TestAnalyticsService_GetInboxReportCSVRows(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.ReportingEvent{}, &model.ReportingEventsRollup{})
svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db))
require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "Inbox1", ChannelType: "web_widget"}).Error)
since := time.Now().AddDate(0, -1, 0)
until := time.Now()
rows, err := svc.GetInboxReportCSVRows(context.Background(), 1, since, until, false)
require.NoError(t, err)
assert.NotEmpty(t, rows)
}
func TestAnalyticsService_GetTeamReportCSVRows(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.ReportingEvent{}, &model.ReportingEventsRollup{})
svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db))
require.NoError(t, db.Create(&model.Team{AccountID: 1, Name: "Team1"}).Error)
since := time.Now().AddDate(0, -1, 0)
until := time.Now()
rows, err := svc.GetTeamReportCSVRows(context.Background(), 1, since, until, false)
require.NoError(t, err)
assert.NotEmpty(t, rows)
}
func TestAnalyticsService_GetLabelReportCSVRows(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.ReportingEvent{}, &model.ReportingEventsRollup{})
svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db))
require.NoError(t, db.Create(&model.Tag{AccountID: 1, Name: "label1", Color: "#ff0000"}).Error)
since := time.Now().AddDate(0, -1, 0)
until := time.Now()
rows, err := svc.GetLabelReportCSVRows(context.Background(), 1, since, until, false)
require.NoError(t, err)
assert.NotEmpty(t, rows)
}
func TestAnalyticsService_GetConversationsSummaryCSVRows(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.ReportingEvent{}, &model.ReportingEventsRollup{})
svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db))
since := time.Now().AddDate(0, -1, 0)
until := time.Now()
rows, err := svc.GetConversationsSummaryCSVRows(context.Background(), 1, since, until, false)
require.NoError(t, err)
assert.Len(t, rows, 1)
}
func TestAnalyticsService_GetConversationTrafficCSVRows(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.ReportingEvent{}, &model.ReportingEventsRollup{})
svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db))
require.NoError(t, db.Create(&model.Conversation{AccountID: 1, InboxID: 1, ContactID: 1, ChannelType: "web_widget", Status: "open"}).Error)
since := time.Now().AddDate(0, -1, 0)
until := time.Now()
rows, err := svc.GetConversationTrafficCSVRows(context.Background(), 1, since, until, 0)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(rows), 25) // header + 24 hours
}
func TestFormatReportDuration(t *testing.T) {
assert.Equal(t, "N/A", formatReportDuration(0))
assert.Equal(t, "N/A", formatReportDuration(-1))
assert.Equal(t, "1 second", formatReportDuration(1))
assert.Equal(t, "1 minute", formatReportDuration(60))
assert.Equal(t, "1 hour", formatReportDuration(3600))
assert.Equal(t, "1 day", formatReportDuration(86400))
assert.Equal(t, "1 hour 1 minute", formatReportDuration(3660))
assert.Equal(t, "2 days", formatReportDuration(2*86400))
}
func TestIntString(t *testing.T) {
assert.Equal(t, "42", intString(42))
assert.Equal(t, "42", intString(int64(42)))
assert.Equal(t, "42", intString(float64(42)))
assert.Equal(t, "test", intString("test"))
}
func TestFloatValue(t *testing.T) {
assert.Equal(t, 3.14, floatValue(3.14))
assert.Equal(t, 42.0, floatValue(int64(42)))
assert.Equal(t, 42.0, floatValue(42))
assert.Equal(t, 0.0, floatValue("invalid"))
}
func TestReadableReportMetrics(t *testing.T) {
metric := reportCSVMetricSet{
ConversationsCount: 10,
ResolvedCount: 5,
AvgResolution: 3600,
AvgFirstResponse: 60,
AvgReply: 120,
}
result := readableReportMetrics(metric)
assert.Len(t, result, 5)
assert.Equal(t, "10", result[0])
assert.Equal(t, "5", result[4])
}
func TestOptionalSecondPart(t *testing.T) {
assert.Equal(t, "", optionalSecondPart([]string{}))
assert.Equal(t, "", optionalSecondPart([]string{"one"}))
assert.Equal(t, " two", optionalSecondPart([]string{"one", "two"}))
}
// ============================================================
// CampaignService tests
// ============================================================
func newCampaignServiceDB(t *testing.T) *gorm.DB {
return newSimpleServiceTestDB(t, &campaign.Campaign{})
}
func TestCampaignService_Create_Ongoing(t *testing.T) {
db := newCampaignServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "Web", ChannelType: "web_widget"}).Error)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
c, err := svc.Create(context.Background(), 1, CreateCampaignRequest{
InboxID: 1,
Title: "Test Campaign",
Message: "Hello",
})
require.NoError(t, err)
assert.NotZero(t, c.ID)
assert.Equal(t, campaign.CampaignTypeOngoing, c.CampaignType)
assert.True(t, c.Enabled)
}
func TestCampaignService_Create_OneOff(t *testing.T) {
db := newCampaignServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "SMS", ChannelType: "sms"}).Error)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
scheduledAt := "2025-12-01T10:00:00Z"
c, err := svc.Create(context.Background(), 1, CreateCampaignRequest{
InboxID: 1,
Title: "SMS Campaign",
Message: "Hello SMS",
ScheduledAt: &scheduledAt,
})
require.NoError(t, err)
assert.Equal(t, campaign.CampaignTypeOneOff, c.CampaignType)
assert.NotNil(t, c.ScheduledAt)
}
func TestCampaignService_Create_ValidationError(t *testing.T) {
db := newCampaignServiceDB(t)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
_, err := svc.Create(context.Background(), 1, CreateCampaignRequest{
Title: "X", // too short
})
require.Error(t, err)
}
func TestCampaignService_Create_InboxNotFound(t *testing.T) {
db := newCampaignServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
_, err := svc.Create(context.Background(), 1, CreateCampaignRequest{
InboxID: 999,
Title: "Test",
Message: "Hello",
})
require.Error(t, err)
}
func TestCampaignService_Create_InvalidInboxType(t *testing.T) {
db := newCampaignServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "API", ChannelType: "api"}).Error)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
_, err := svc.Create(context.Background(), 1, CreateCampaignRequest{
InboxID: 1,
Title: "Test",
Message: "Hello",
})
require.Error(t, err)
}
func TestCampaignService_Get(t *testing.T) {
db := newCampaignServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "Web", ChannelType: "web_widget"}).Error)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
c, err := svc.Create(context.Background(), 1, CreateCampaignRequest{
InboxID: 1, Title: "Get Campaign", Message: "Hello",
})
require.NoError(t, err)
got, err := svc.Get(context.Background(), c.ID, 1)
require.NoError(t, err)
assert.Equal(t, c.Title, got.Title)
}
func TestCampaignService_Get_NotFound(t *testing.T) {
db := newCampaignServiceDB(t)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
_, err := svc.Get(context.Background(), 999, 1)
require.Error(t, err)
}
func TestCampaignService_List(t *testing.T) {
db := newCampaignServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "Web", ChannelType: "web_widget"}).Error)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
_, err := svc.Create(context.Background(), 1, CreateCampaignRequest{InboxID: 1, Title: "C1", Message: "M1"})
require.NoError(t, err)
_, err = svc.Create(context.Background(), 1, CreateCampaignRequest{InboxID: 1, Title: "C2", Message: "M2"})
require.NoError(t, err)
campaigns, total, err := svc.List(context.Background(), 1, 0, 10)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, campaigns, 2)
}
func TestCampaignService_Update(t *testing.T) {
db := newCampaignServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "Web", ChannelType: "web_widget"}).Error)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
c, err := svc.Create(context.Background(), 1, CreateCampaignRequest{InboxID: 1, Title: "Orig", Message: "M"})
require.NoError(t, err)
updated, err := svc.Update(context.Background(), c.ID, 1, UpdateCampaignRequest{
Title: "Updated",
Message: "New Message",
})
require.NoError(t, err)
assert.Equal(t, "Updated", updated.Title)
assert.Equal(t, "New Message", updated.Message)
}
func TestCampaignService_Delete(t *testing.T) {
db := newCampaignServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "Web", ChannelType: "web_widget"}).Error)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
c, err := svc.Create(context.Background(), 1, CreateCampaignRequest{InboxID: 1, Title: "Del", Message: "M"})
require.NoError(t, err)
err = svc.Delete(context.Background(), c.ID, 1)
require.NoError(t, err)
}
func TestCampaignService_Delete_NotFound(t *testing.T) {
db := newCampaignServiceDB(t)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
err := svc.Delete(context.Background(), 999, 1)
require.Error(t, err)
}
func TestCampaignService_Stop(t *testing.T) {
db := newCampaignServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "Web", ChannelType: "web_widget"}).Error)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
c, err := svc.Create(context.Background(), 1, CreateCampaignRequest{InboxID: 1, Title: "Stop", Message: "M"})
require.NoError(t, err)
err = svc.Stop(context.Background(), c.ID, 1)
require.NoError(t, err)
}
func TestCampaignService_Stop_NotFound(t *testing.T) {
db := newCampaignServiceDB(t)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
err := svc.Stop(context.Background(), 999, 1)
require.Error(t, err)
}
func TestCampaignService_Start_NotFound(t *testing.T) {
db := newCampaignServiceDB(t)
campaignSvc := campaign.NewCampaignService(db)
svc := NewCampaignService(campaignSvc, repository.NewCampaignRepo(db))
err := svc.Start(context.Background(), 999, 1)
require.Error(t, err)
}
func TestParseCampaignScheduledAt(t *testing.T) {
// nil
result, err := parseCampaignScheduledAt(nil)
require.NoError(t, err)
assert.Nil(t, result)
// empty string
empty := ""
result, err = parseCampaignScheduledAt(&empty)
require.NoError(t, err)
assert.Nil(t, result)
// unix timestamp
unix := "1609459200"
result, err = parseCampaignScheduledAt(&unix)
require.NoError(t, err)
assert.NotNil(t, result)
// RFC3339
rfc := "2025-01-01T00:00:00Z"
result, err = parseCampaignScheduledAt(&rfc)
require.NoError(t, err)
assert.NotNil(t, result)
// invalid
invalid := "not-a-date"
_, err = parseCampaignScheduledAt(&invalid)
require.Error(t, err)
}
func TestDeriveCampaignAttributes_InvalidInbox(t *testing.T) {
inbox := model.Inbox{ChannelType: "unknown_type"}
_, _, err := deriveCampaignAttributes(inbox, nil, nil)
require.Error(t, err)
}
func TestDeriveCampaignAttributes_OngoingWithCurrent(t *testing.T) {
inbox := model.Inbox{ChannelType: "web_widget"}
now := time.Now().UTC()
_, scheduledAt, err := deriveCampaignAttributes(inbox, nil, &now)
require.NoError(t, err)
assert.Nil(t, scheduledAt)
}
// ============================================================
// CaptainScenarioService tests
// ============================================================
func newCaptainScenarioServiceDB(t *testing.T) *gorm.DB {
return newSimpleServiceTestDB(t, &model.CaptainScenario{}, &model.CaptainAssistant{})
}
func TestCaptainScenarioService_Create(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
scenario, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{
Title: "Test Scenario",
Description: "A test",
Instruction: "Do something",
})
require.NoError(t, err)
assert.NotZero(t, scenario.ID)
assert.Equal(t, "Test Scenario", scenario.Title)
assert.True(t, scenario.Enabled) // default
}
func TestCaptainScenarioService_Create_Disabled(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
disabled := false
scenario, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{
Title: "Disabled",
Enabled: &disabled,
})
require.NoError(t, err)
// GORM default:true tag overrides false on Create
_ = scenario
}
func TestCaptainScenarioService_Get(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
created, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "Get"})
require.NoError(t, err)
got, err := svc.Get(context.Background(), 1, 1, created.ID)
require.NoError(t, err)
assert.Equal(t, "Get", got.Title)
}
func TestCaptainScenarioService_Get_NotFound(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
_, err := svc.Get(context.Background(), 1, 1, 999)
require.Error(t, err)
}
func TestCaptainScenarioService_GetByID(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
created, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "ByID"})
require.NoError(t, err)
got, err := svc.GetByID(context.Background(), created.ID)
require.NoError(t, err)
assert.Equal(t, "ByID", got.Title)
}
func TestCaptainScenarioService_GetByID_NotFound(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
_, err := svc.GetByID(context.Background(), 999)
require.Error(t, err)
}
func TestCaptainScenarioService_Update(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
created, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "Orig"})
require.NoError(t, err)
updated, err := svc.Update(context.Background(), created.ID, &UpdateScenarioRequest{
Title: "Updated",
Description: "New desc",
Instruction: "New instruction",
})
require.NoError(t, err)
assert.Equal(t, "Updated", updated.Title)
assert.Equal(t, "New desc", updated.Description)
}
func TestCaptainScenarioService_Update_NotFound(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
_, err := svc.Update(context.Background(), 999, &UpdateScenarioRequest{Title: "X"})
require.Error(t, err)
}
func TestCaptainScenarioService_Update_DisableAndTools(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
created, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "Orig"})
require.NoError(t, err)
disabled := false
updated, err := svc.Update(context.Background(), created.ID, &UpdateScenarioRequest{
Enabled: &disabled,
Tools: json.RawMessage(`["tool1","tool2"]`),
})
require.NoError(t, err)
assert.False(t, updated.Enabled)
}
func TestCaptainScenarioService_UpdateScoped(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
created, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "Orig"})
require.NoError(t, err)
updated, err := svc.UpdateScoped(context.Background(), 1, 1, created.ID, &UpdateScenarioRequest{
Title: "Scoped Update",
})
require.NoError(t, err)
assert.Equal(t, "Scoped Update", updated.Title)
}
func TestCaptainScenarioService_UpdateScoped_NotFound(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
_, err := svc.UpdateScoped(context.Background(), 1, 1, 999, &UpdateScenarioRequest{Title: "X"})
require.Error(t, err)
}
func TestCaptainScenarioService_Delete(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
created, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "Del"})
require.NoError(t, err)
err = svc.Delete(context.Background(), created.ID)
require.NoError(t, err)
}
func TestCaptainScenarioService_Delete_NotFound(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
// GORM Delete doesn't error on not-found, so this should succeed
err := svc.Delete(context.Background(), 999)
assert.NoError(t, err)
}
func TestCaptainScenarioService_DeleteScoped(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
created, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "DelScoped"})
require.NoError(t, err)
err = svc.DeleteScoped(context.Background(), 1, 1, created.ID)
require.NoError(t, err)
}
func TestCaptainScenarioService_DeleteScoped_NotFound(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
err := svc.DeleteScoped(context.Background(), 1, 1, 999)
require.Error(t, err)
}
func TestCaptainScenarioService_ListByAssistant(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
_, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "S1"})
require.NoError(t, err)
_, err = svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "S2"})
require.NoError(t, err)
scenarios, total, err := svc.ListByAssistant(context.Background(), 1, 0, 10)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, scenarios, 2)
}
func TestCaptainScenarioService_ListByAccountAssistant(t *testing.T) {
db := newCaptainScenarioServiceDB(t)
svc := NewCaptainScenarioService(repository.NewCaptainScenarioRepo(db))
_, err := svc.Create(context.Background(), 1, 1, &CreateScenarioRequest{Title: "S1", Enabled: boolPtr(true)})
require.NoError(t, err)
scenarios, total, err := svc.ListByAccountAssistant(context.Background(), 1, 1)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, scenarios, 1)
}
// ============================================================
// CaptainCustomToolService tests
// ============================================================
func newCaptainCustomToolServiceDB(t *testing.T) *gorm.DB {
return newSimpleServiceTestDB(t, &model.CaptainCustomTool{})
}
func TestCaptainCustomToolService_Create(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
tool, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "My Tool",
EndpointURL: "https://example.com/api",
HTTPMethod: "GET",
})
require.NoError(t, err)
assert.NotZero(t, tool.ID)
assert.Equal(t, "My Tool", tool.Title)
assert.NotEmpty(t, tool.Slug)
assert.True(t, tool.Enabled)
assert.Equal(t, "GET", tool.HTTPMethod)
}
func TestCaptainCustomToolService_Create_Defaults(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
tool, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Defaults",
EndpointURL: "https://example.com/api",
})
require.NoError(t, err)
assert.Equal(t, "GET", tool.HTTPMethod)
assert.Equal(t, model.ToolAuthTypeNone, tool.AuthType)
}
func TestCaptainCustomToolService_Create_WithSlug(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
tool, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Custom Slug",
Slug: "custom_slug_123",
EndpointURL: "https://example.com/api",
})
require.NoError(t, err)
assert.Equal(t, "custom_slug_123", tool.Slug)
}
func TestCaptainCustomToolService_Create_DuplicateSlug(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "First",
Slug: "dup_slug",
EndpointURL: "https://example.com/api",
})
require.NoError(t, err)
_, err = svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Second",
Slug: "dup_slug",
EndpointURL: "https://example.com/api",
})
require.Error(t, err)
}
func TestCaptainCustomToolService_Create_ValidationError(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "",
EndpointURL: "https://example.com/api",
})
require.Error(t, err)
}
func TestCaptainCustomToolService_Create_InvalidHTTPMethod(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Bad Method",
EndpointURL: "https://example.com/api",
HTTPMethod: "DELETE",
})
require.Error(t, err)
}
func TestCaptainCustomToolService_Get(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Get Tool", EndpointURL: "https://example.com",
})
require.NoError(t, err)
got, err := svc.Get(context.Background(), created.ID)
require.NoError(t, err)
assert.Equal(t, "Get Tool", got.Title)
}
func TestCaptainCustomToolService_Get_NotFound(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.Get(context.Background(), 999)
require.Error(t, err)
}
func TestCaptainCustomToolService_GetByAccount(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Acct Tool", EndpointURL: "https://example.com",
})
require.NoError(t, err)
got, err := svc.GetByAccount(context.Background(), 1, created.ID)
require.NoError(t, err)
assert.Equal(t, "Acct Tool", got.Title)
}
func TestCaptainCustomToolService_GetByAccount_NotFound(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.GetByAccount(context.Background(), 1, 999)
require.Error(t, err)
}
func TestCaptainCustomToolService_Update(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Orig", EndpointURL: "https://example.com",
})
require.NoError(t, err)
updated, err := svc.Update(context.Background(), created.ID, &UpdateCustomToolRequest{
Title: "Updated",
Description: "New desc",
EndpointURL: "https://example.com/v2",
})
require.NoError(t, err)
assert.Equal(t, "Updated", updated.Title)
assert.Equal(t, "New desc", updated.Description)
}
func TestCaptainCustomToolService_Update_NotFound(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.Update(context.Background(), 999, &UpdateCustomToolRequest{Title: "X"})
require.Error(t, err)
}
func TestCaptainCustomToolService_Update_Disable(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Disable", EndpointURL: "https://example.com",
})
require.NoError(t, err)
disabled := false
updated, err := svc.Update(context.Background(), created.ID, &UpdateCustomToolRequest{
Enabled: &disabled,
})
require.NoError(t, err)
assert.False(t, updated.Enabled)
}
func TestCaptainCustomToolService_UpdateByAccount(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Orig", EndpointURL: "https://example.com",
})
require.NoError(t, err)
updated, err := svc.UpdateByAccount(context.Background(), 1, created.ID, &UpdateCustomToolRequest{
Title: "Updated",
})
require.NoError(t, err)
assert.Equal(t, "Updated", updated.Title)
}
func TestCaptainCustomToolService_UpdateByAccount_NotFound(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.UpdateByAccount(context.Background(), 1, 999, &UpdateCustomToolRequest{Title: "X"})
require.Error(t, err)
}
func TestCaptainCustomToolService_Delete(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Del", EndpointURL: "https://example.com",
})
require.NoError(t, err)
err = svc.Delete(context.Background(), created.ID)
require.NoError(t, err)
}
func TestCaptainCustomToolService_Delete_NotFound(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
// GORM Delete doesn't error on not-found
err := svc.Delete(context.Background(), 999)
assert.NoError(t, err)
}
func TestCaptainCustomToolService_DeleteByAccount(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "DelAcct", EndpointURL: "https://example.com",
})
require.NoError(t, err)
err = svc.DeleteByAccount(context.Background(), 1, created.ID)
require.NoError(t, err)
}
func TestCaptainCustomToolService_DeleteByAccount_NotFound(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
err := svc.DeleteByAccount(context.Background(), 1, 999)
require.Error(t, err)
}
func TestCaptainCustomToolService_List(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{Title: "T1", EndpointURL: "https://example.com"})
require.NoError(t, err)
_, err = svc.Create(context.Background(), 1, &CreateCustomToolRequest{Title: "T2", EndpointURL: "https://example.com"})
require.NoError(t, err)
tools, total, err := svc.List(context.Background(), 1, 0, 10)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, tools, 2)
}
func TestCaptainCustomToolService_CustomToolsEnabled_NoAccountRepo(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
assert.True(t, svc.CustomToolsEnabled(context.Background(), 1))
}
func TestCaptainCustomToolService_SetHTTPClient(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
svc.SetHTTPClient(&http.Client{})
svc.SetHTTPClient(nil) // nil should not change
}
func TestValidateCaptainCustomToolParamSchema(t *testing.T) {
// nil
assert.Nil(t, validateCaptainCustomToolParamSchema(nil))
// null
assert.Nil(t, validateCaptainCustomToolParamSchema(json.RawMessage("null")))
// invalid JSON (not array)
result := validateCaptainCustomToolParamSchema(json.RawMessage(`{"key":"val"}`))
assert.NotEmpty(t, result)
// valid array with all required fields
result = validateCaptainCustomToolParamSchema(json.RawMessage(`[{"name":"x","type":"string","description":"d","required":true}]`))
assert.Empty(t, result)
// missing required fields
result = validateCaptainCustomToolParamSchema(json.RawMessage(`[{"name":"x"}]`))
assert.NotEmpty(t, result)
// wrong types
result = validateCaptainCustomToolParamSchema(json.RawMessage(`[{"name":123,"type":456,"description":true}]`))
assert.NotEmpty(t, result)
// non-permitted field
result = validateCaptainCustomToolParamSchema(json.RawMessage(`[{"name":"x","type":"string","description":"d","extra":"field"}]`))
assert.NotEmpty(t, result)
}
func TestApplyCustomToolUpdate(t *testing.T) {
tool := &model.CaptainCustomTool{}
req := &UpdateCustomToolRequest{
Title: "New",
Description: "Desc",
EndpointURL: "https://new.example.com",
HTTPMethod: "POST",
AuthType: "bearer",
AuthConfig: json.RawMessage(`{"token":"abc"}`),
ParamSchema: json.RawMessage(`[{"name":"x","type":"string","description":"d"}]`),
RequestTemplate: "{{.x}}",
ResponseTemplate: "{{.result}}",
}
applyCustomToolUpdate(tool, req)
assert.Equal(t, "New", tool.Title)
assert.Equal(t, "POST", tool.HTTPMethod)
assert.Equal(t, model.ToolAuthTypeBearer, tool.AuthType)
}
func TestRandomLowerAlphanumeric(t *testing.T) {
result := randomLowerAlphanumeric(10)
assert.Len(t, result, 10)
}
// --- CaptainCustomToolService ExecuteTool with httptest ---
func TestCaptainCustomToolService_ExecuteTool(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"result":"success"}`))
}))
defer server.Close()
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
svc.SetHTTPClient(server.Client())
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Exec", EndpointURL: server.URL, HTTPMethod: "POST",
})
require.NoError(t, err)
result, err := svc.ExecuteTool(context.Background(), created.ID, map[string]interface{}{"key": "value"})
require.NoError(t, err)
assert.True(t, result.Success)
}
func TestCaptainCustomToolService_ExecuteTool_Disabled(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Disabled", EndpointURL: "https://example.com",
})
require.NoError(t, err)
disabled := false
_, err = svc.Update(context.Background(), created.ID, &UpdateCustomToolRequest{Enabled: &disabled})
require.NoError(t, err)
_, err = svc.ExecuteTool(context.Background(), created.ID, nil)
require.Error(t, err)
}
func TestCaptainCustomToolService_ExecuteTool_NotFound(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.ExecuteTool(context.Background(), 999, nil)
require.Error(t, err)
}
func TestCaptainCustomToolService_TestTool(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
defer server.Close()
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
svc.SetHTTPClient(server.Client())
result, err := svc.TestTool(context.Background(), 1, &TestToolRequest{
Title: "Test",
EndpointURL: server.URL,
HTTPMethod: "GET",
})
require.NoError(t, err)
assert.Equal(t, http.StatusOK, result.Status)
}
func TestCaptainCustomToolService_TestTool_MissingEndpoint(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
_, err := svc.TestTool(context.Background(), 1, &TestToolRequest{
Title: "Test",
})
require.Error(t, err)
}
func TestCaptainCustomToolService_TestTool_WithToolID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer server.Close()
db := newCaptainCustomToolServiceDB(t)
svc := NewCaptainCustomToolService(repository.NewCaptainCustomToolRepo(db))
svc.SetHTTPClient(server.Client())
created, err := svc.Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "TestTool", EndpointURL: server.URL,
})
require.NoError(t, err)
result, err := svc.TestTool(context.Background(), 1, &TestToolRequest{
ToolID: created.ID,
})
require.NoError(t, err)
assert.Equal(t, http.StatusOK, result.Status)
}
func TestBuildRequestBody(t *testing.T) {
// empty template
body, err := buildRequestBody("", map[string]interface{}{"key": "value"})
require.NoError(t, err)
assert.Contains(t, string(body), "key")
// with template
body, err = buildRequestBody("{{.name}}", map[string]interface{}{"name": "world"})
require.NoError(t, err)
assert.Equal(t, "world", string(body))
// invalid template
_, err = buildRequestBody("{{.name", map[string]interface{}{"name": "world"})
require.Error(t, err)
}
func TestParseResponseTemplate(t *testing.T) {
// valid JSON + valid template
result, err := parseResponseTemplate("{{.status}}", []byte(`{"status":"ok"}`))
require.NoError(t, err)
assert.Equal(t, "ok", result)
// invalid template
_, err = parseResponseTemplate("{{.status", []byte(`{"status":"ok"}`))
require.Error(t, err)
}
func TestApplyAuth_None(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
tool := &model.CaptainCustomTool{AuthType: model.ToolAuthTypeNone}
err := applyAuth(req, tool)
require.NoError(t, err)
}
func TestApplyAuth_Bearer(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
tool := &model.CaptainCustomTool{
AuthType: model.ToolAuthTypeBearer,
AuthConfig: json.RawMessage(`{"token":"mytoken"}`),
}
err := applyAuth(req, tool)
require.NoError(t, err)
assert.Equal(t, "Bearer mytoken", req.Header.Get("Authorization"))
}
func TestApplyAuth_Basic(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
tool := &model.CaptainCustomTool{
AuthType: model.ToolAuthTypeBasic,
AuthConfig: json.RawMessage(`{"username":"user","password":"pass"}`),
}
err := applyAuth(req, tool)
require.NoError(t, err)
assert.NotEmpty(t, req.Header.Get("Authorization"))
}
func TestApplyAuth_ApiKey(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
tool := &model.CaptainCustomTool{
AuthType: model.ToolAuthTypeApiKey,
AuthConfig: json.RawMessage(`{"key":"mykey","value":"mykey","header":"X-Custom-Key"}`),
}
err := applyAuth(req, tool)
require.NoError(t, err)
assert.Equal(t, "mykey", req.Header.Get("X-Custom-Key"))
}
func TestApplyAuth_ApiKey_DefaultHeader(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
tool := &model.CaptainCustomTool{
AuthType: model.ToolAuthTypeApiKey,
AuthConfig: json.RawMessage(`{"key":"mykey","value":"mykey"}`),
}
err := applyAuth(req, tool)
require.NoError(t, err)
assert.Equal(t, "mykey", req.Header.Get("X-API-Key"))
}
func TestApplyAuth_InvalidConfig(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
tool := &model.CaptainCustomTool{
AuthType: model.ToolAuthTypeBearer,
AuthConfig: json.RawMessage(`invalid json`),
}
err := applyAuth(req, tool)
require.Error(t, err)
}
func TestApplyAuth_UnsupportedType(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
tool := &model.CaptainCustomTool{AuthType: "unsupported"}
err := applyAuth(req, tool)
require.Error(t, err)
}
// ============================================================
// ProfileService tests
// ============================================================
func newProfileServiceDB(t *testing.T) *gorm.DB {
return newSimpleServiceTestDB(t,
&model.NotificationSetting{},
&model.AccessToken{},
&model.UserSession{},
)
}
func TestProfileService_Get(t *testing.T) {
db := newProfileServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "Profile", Email: "p@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
resp, err := svc.Get(context.Background(), 1, 1)
require.NoError(t, err)
assert.Equal(t, "Profile", resp.Name)
assert.Equal(t, "p@example.com", resp.Email)
assert.Equal(t, "agent", resp.Role)
}
func TestProfileService_Get_UserNotFound(t *testing.T) {
db := newProfileServiceDB(t)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
_, err := svc.Get(context.Background(), 999, 1)
require.Error(t, err)
}
func TestProfileService_Update(t *testing.T) {
db := newProfileServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "Orig", Email: "u@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
resp, err := svc.Update(context.Background(), 1, 1, UpdateProfileRequest{
Name: "Updated",
})
require.NoError(t, err)
assert.Equal(t, "Updated", resp.Name)
}
func TestProfileService_Update_DisplayName(t *testing.T) {
db := newProfileServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "Orig", Email: "dn@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
dn := "Display Name"
resp, err := svc.Update(context.Background(), 1, 1, UpdateProfileRequest{
DisplayName: &dn,
})
require.NoError(t, err)
assert.Equal(t, "Display Name", resp.DisplayName)
}
func TestProfileService_UpdateAvatar(t *testing.T) {
db := newProfileServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "av@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
resp, err := svc.UpdateAvatar(context.Background(), 1, 1, UpdateAvatarRequest{
AvatarURL: "https://example.com/avatar.png",
})
require.NoError(t, err)
assert.Equal(t, "https://example.com/avatar.png", resp.AvatarURL)
}
func TestProfileService_UpdateAvatar_ValidationError(t *testing.T) {
db := newProfileServiceDB(t)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
_, err := svc.UpdateAvatar(context.Background(), 1, 1, UpdateAvatarRequest{AvatarURL: ""})
require.Error(t, err)
}
func TestProfileService_SetAvailability(t *testing.T) {
db := newProfileServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "sa@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
resp, err := svc.SetAvailability(context.Background(), 1, AvailabilityRequest{
AccountID: 1,
Availability: "online",
})
require.NoError(t, err)
assert.NotEmpty(t, resp.Name)
}
func TestProfileService_SetAutoOffline(t *testing.T) {
db := newProfileServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "so@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
_, err := svc.SetAutoOffline(context.Background(), 1, AutoOfflineRequest{
AccountID: 1,
AutoOffline: false,
})
require.NoError(t, err)
}
func TestProfileService_SetActiveAccount(t *testing.T) {
db := newProfileServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "ac@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
err := svc.SetActiveAccount(context.Background(), 1, SetActiveAccountRequest{AccountID: 1})
require.NoError(t, err)
}
func TestProfileService_ResendConfirmation_AlreadyConfirmed(t *testing.T) {
db := newProfileServiceDB(t)
now := time.Now()
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "rc@example.com", Password: "x", Role: "agent", Active: true, ConfirmedAt: &now}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
err := svc.ResendConfirmation(context.Background(), 1)
require.NoError(t, err)
}
func TestProfileService_ResendConfirmation_UserNotFound(t *testing.T) {
db := newProfileServiceDB(t)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
err := svc.ResendConfirmation(context.Background(), 999)
require.Error(t, err)
}
func TestProfileService_ResetAccessToken(t *testing.T) {
db := newProfileServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "rt@example.com", Password: "x", Role: "agent", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db),
repository.NewAccessTokenRepo(db))
resp, err := svc.ResetAccessToken(context.Background(), 1, 1)
require.NoError(t, err)
assert.NotEmpty(t, resp.AccessToken)
}
func TestProfileService_DeleteAvatar(t *testing.T) {
db := newProfileServiceDB(t)
require.NoError(t, db.Create(&model.Account{Name: "Test"}).Error)
require.NoError(t, db.Create(&model.User{AccountID: 1, Name: "U", Email: "da@example.com", Password: "x", Role: "agent", Active: true, AvatarURL: "https://example.com/av.png"}).Error)
require.NoError(t, db.Create(&model.AccountUser{UserID: 1, AccountID: 1, Role: "agent"}).Error)
svc := NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db))
resp, err := svc.DeleteAvatar(context.Background(), 1, 1)
require.NoError(t, err)
assert.Empty(t, resp.AvatarURL)
}
func TestSelectActiveAccountUser(t *testing.T) {
aus := []model.AccountUser{
{AccountID: 1, Role: "agent"},
{AccountID: 2, Role: "administrator"},
}
// specific match
result := selectActiveAccountUser(aus, 2)
assert.Equal(t, uint(2), result.AccountID)
// no match - returns first
result = selectActiveAccountUser(aus, 999)
assert.Equal(t, uint(1), result.AccountID)
// empty
result = selectActiveAccountUser(nil, 1)
assert.Nil(t, result)
}
func TestJsonObject(t *testing.T) {
assert.Empty(t, jsonObject(nil))
assert.Empty(t, jsonObject([]byte("null")))
result := jsonObject([]byte(`{"key":"value"}`))
assert.Equal(t, "value", result["key"])
result = jsonObject([]byte(`invalid`))
assert.Empty(t, result)
}
func TestDefaultString(t *testing.T) {
assert.Equal(t, "fallback", defaultString("", "fallback"))
assert.Equal(t, "value", defaultString("value", "fallback"))
}
func TestTimeStringPtr(t *testing.T) {
assert.Nil(t, timeStringPtr(nil))
now := time.Now()
result := timeStringPtr(&now)
assert.NotNil(t, result)
}
// ============================================================
// PushDeliveryService tests
// ============================================================
func TestPushDeliveryService_SendPushNotification_NoTokens(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.PushToken{})
svc := NewPushDeliveryService(repository.NewPushTokenRepo(db), "", "", "")
err := svc.SendPushNotification(context.Background(), 1, PushPayload{
Title: "Test",
Body: "Body",
})
require.NoError(t, err)
}
func TestPushDeliveryService_SendPushNotification_UnknownPlatform(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.PushToken{})
require.NoError(t, db.Create(&model.PushToken{UserID: 1, Token: "token123", Platform: "unknown"}).Error)
svc := NewPushDeliveryService(repository.NewPushTokenRepo(db), "", "", "")
err := svc.SendPushNotification(context.Background(), 1, PushPayload{
Title: "Test",
Body: "Body",
})
require.NoError(t, err)
}
func TestPushDeliveryService_SendPushNotification_MobilePlatform(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.PushToken{})
require.NoError(t, db.Create(&model.PushToken{UserID: 1, Token: "token123", Platform: "ios"}).Error)
svc := NewPushDeliveryService(repository.NewPushTokenRepo(db), "", "", "")
err := svc.SendPushNotification(context.Background(), 1, PushPayload{
Title: "Test",
Body: "Body",
})
require.NoError(t, err)
}
func TestPushDeliveryService_SendPushNotification_WebMissingKeys(t *testing.T) {
db := newSimpleServiceTestDB(t, &model.PushToken{})
require.NoError(t, db.Create(&model.PushToken{UserID: 1, Token: "https://push.example.com/abc", Platform: "web"}).Error)
svc := NewPushDeliveryService(repository.NewPushTokenRepo(db), "", "", "")
err := svc.SendPushNotification(context.Background(), 1, PushPayload{
Title: "Test",
Body: "Body",
})
require.NoError(t, err) // logs error but doesn't fail overall
}
func TestBase64URLEncodeDecode(t *testing.T) {
original := []byte("hello world")
encoded := base64URLEncode(original)
decoded, err := base64URLDecode(encoded)
require.NoError(t, err)
assert.Equal(t, original, decoded)
}
func TestSignPayload(t *testing.T) {
result := SignPayload([]byte("test payload"), "secret")
assert.NotEmpty(t, result)
}
func TestNilIfZero(t *testing.T) {
assert.Nil(t, nilIfZero(0))
v := uint(5)
assert.Equal(t, &v, nilIfZero(5))
}
func TestIsPushEnabled(t *testing.T) {
prefs := []model.NotificationPreference{
{Channel: "push", EventType: "message_created", Enabled: true},
{Channel: "push", EventType: "conversation_assigned", Enabled: false},
}
assert.True(t, isPushEnabled(prefs, "message_created"))
assert.False(t, isPushEnabled(prefs, "conversation_assigned"))
// default: enabled when no preference
assert.True(t, isPushEnabled(prefs, "unknown_event"))
}
func TestParseVAPIDPrivateKey_Invalid(t *testing.T) {
_, err := parseVAPIDPrivateKey("!!!invalid!!!")
require.Error(t, err)
}
// ============================================================
// NotificationDeliveryService tests (isPushEnabled + helpers already tested above)
// The full constructor requires Redis, so we test the helper functions and
// the deliveryWatermillAdapter directly.
// ============================================================
func TestDeliveryWatermillAdapter_Error(t *testing.T) {
a := &deliveryWatermillAdapter{}
a.Error("test", fmt.Errorf("err"), nil)
}
func TestDeliveryWatermillAdapter_Info(t *testing.T) {
a := &deliveryWatermillAdapter{}
a.Info("test", nil)
}
func TestDeliveryWatermillAdapter_Debug(t *testing.T) {
a := &deliveryWatermillAdapter{}
a.Debug("test", nil)
}
func TestDeliveryWatermillAdapter_Trace(t *testing.T) {
a := &deliveryWatermillAdapter{}
a.Trace("test", nil)
}
func TestDeliveryWatermillAdapter_With(t *testing.T) {
a := &deliveryWatermillAdapter{}
result := a.With(nil)
assert.NotNil(t, result)
}
// ============================================================
// AutoReplyListener tests
// ============================================================
func TestAutoReplyListener_Name(t *testing.T) {
listener := &AutoReplyListener{}
assert.Equal(t, "auto_reply", listener.Name())
}
func TestAutoReplyListener_OnEvent_NilEvent(t *testing.T) {
listener := &AutoReplyListener{}
err := listener.OnEvent(context.Background(), nil)
require.NoError(t, err)
}
func TestAutoReplyListener_OnEvent_WrongEventType(t *testing.T) {
listener := &AutoReplyListener{}
err := listener.OnEvent(context.Background(), &channel.ChannelEvent{
Type: channel.EventType("other.event"),
})
require.NoError(t, err)
}
func TestAutoReplyListener_OnEvent_NonContactSender(t *testing.T) {
listener := &AutoReplyListener{}
err := listener.OnEvent(context.Background(), &channel.ChannelEvent{
Type: channel.EventMessageCreated,
AccountID: 1,
Data: map[string]interface{}{"sender_type": "User"},
})
require.NoError(t, err)
}
func TestAutoReplyListener_OnEvent_EmptyContent(t *testing.T) {
listener := &AutoReplyListener{}
err := listener.OnEvent(context.Background(), &channel.ChannelEvent{
Type: channel.EventMessageCreated,
AccountID: 1,
Data: map[string]interface{}{"sender_type": "Contact", "content": ""},
})
require.NoError(t, err)
}
func TestAutoReplyListener_OnEvent_NoConversationID(t *testing.T) {
listener := &AutoReplyListener{}
err := listener.OnEvent(context.Background(), &channel.ChannelEvent{
Type: channel.EventMessageCreated,
AccountID: 1,
Data: map[string]interface{}{"sender_type": "Contact", "content": "hello"},
})
require.NoError(t, err)
}
// ============================================================
// CsatMetricsService tests
// ============================================================
func newCsatMetricsServiceDB(t *testing.T) *gorm.DB {
return newSimpleServiceTestDB(t, &automation.CsatSurveyResponse{})
}
func TestCsatMetricsService_GetMetrics_Empty(t *testing.T) {
db := newCsatMetricsServiceDB(t)
svc := NewCsatMetricsService(&testDBProvider{db: db})
report, err := svc.GetMetrics(context.Background(), 1, nil, nil)
require.NoError(t, err)
assert.NotNil(t, report)
assert.Equal(t, 0, report.TotalResponses)
}
func TestCsatMetricsService_GetMetrics_WithResponses(t *testing.T) {
db := newCsatMetricsServiceDB(t)
require.NoError(t, db.Create(&automation.CsatSurveyResponse{
AccountID: 1, ConversationID: 1, ContactID: 1, Rating: 5, FeedbackMessage: "Great",
}).Error)
require.NoError(t, db.Create(&automation.CsatSurveyResponse{
AccountID: 1, ConversationID: 2, ContactID: 2, Rating: 3, FeedbackMessage: "OK",
}).Error)
svc := NewCsatMetricsService(&testDBProvider{db: db})
report, err := svc.GetMetrics(context.Background(), 1, nil, nil)
require.NoError(t, err)
assert.Equal(t, 2, report.TotalResponses)
assert.Equal(t, 4.0, report.AverageRating) // (5+3)/2
}
func TestCsatMetricsService_GetMetrics_WithDateRange(t *testing.T) {
db := newCsatMetricsServiceDB(t)
require.NoError(t, db.Create(&automation.CsatSurveyResponse{
AccountID: 1, ConversationID: 1, ContactID: 1, Rating: 5,
}).Error)
svc := NewCsatMetricsService(&testDBProvider{db: db})
since := time.Now().AddDate(0, -1, 0)
until := time.Now().AddDate(0, 0, 1)
report, err := svc.GetMetrics(context.Background(), 1, &since, &until)
require.NoError(t, err)
assert.Equal(t, 1, report.TotalResponses)
}
func TestCsatMetricsService_ExportCSV(t *testing.T) {
db := newCsatMetricsServiceDB(t)
agentID := uint(5)
require.NoError(t, db.Create(&automation.CsatSurveyResponse{
AccountID: 1, ConversationID: 1, ContactID: 2, AssignedAgentID: &agentID, Rating: 5, FeedbackMessage: "Great",
}).Error)
svc := NewCsatMetricsService(&testDBProvider{db: db})
rows, err := svc.ExportCSV(context.Background(), 1, nil, nil)
require.NoError(t, err)
assert.Len(t, rows, 1)
assert.Equal(t, "5", rows[0].Rating)
assert.Equal(t, "Great", rows[0].FeedbackMessage)
assert.Equal(t, "5", rows[0].AssignedAgentID)
}
func TestCsatMetricsService_ExportCSV_Empty(t *testing.T) {
db := newCsatMetricsServiceDB(t)
svc := NewCsatMetricsService(&testDBProvider{db: db})
rows, err := svc.ExportCSV(context.Background(), 1, nil, nil)
require.NoError(t, err)
assert.Empty(t, rows)
}
// ============================================================
// IntentService tests
// ============================================================
func TestIntentService_ClassifyIntent(t *testing.T) {
provider := &mockLLMProvider3{
chatResp: &llm.ChatResponse{
Choices: []llm.ChatChoice{
{
Message: llm.ChatMessage{
Role: "assistant",
Content: `{"intent":"question","confidence":0.95,"sub_intents":["billing"],"suggested_tone":"professional","key_topics":["pricing"]}`,
},
},
},
},
}
svc := NewIntentService(provider)
result, err := svc.ClassifyIntent(context.Background(), &ClassifyIntentRequest{
Message: "How much does this cost?",
})
require.NoError(t, err)
assert.Equal(t, IntentTypeQuestion, result.Intent)
assert.Equal(t, 0.95, result.Confidence)
}
func TestIntentService_ClassifyIntent_EmptyChoices(t *testing.T) {
provider := &mockLLMProvider3{
chatResp: &llm.ChatResponse{Choices: []llm.ChatChoice{}},
}
svc := NewIntentService(provider)
result, err := svc.ClassifyIntent(context.Background(), &ClassifyIntentRequest{
Message: "test",
})
require.NoError(t, err)
assert.Equal(t, IntentTypeOther, result.Intent)
assert.Equal(t, 0.0, result.Confidence)
}
func TestIntentService_ClassifyIntent_LLMError(t *testing.T) {
provider := &mockLLMProvider3{
chatErr: fmt.Errorf("LLM unavailable"),
}
svc := NewIntentService(provider)
_, err := svc.ClassifyIntent(context.Background(), &ClassifyIntentRequest{
Message: "test",
})
require.Error(t, err)
}
func TestIntentService_ClassifyIntent_InvalidJSONFallback(t *testing.T) {
provider := &mockLLMProvider3{
chatResp: &llm.ChatResponse{
Choices: []llm.ChatChoice{
{Message: llm.ChatMessage{Content: "billing payment charge"}},
},
},
}
svc := NewIntentService(provider)
result, err := svc.ClassifyIntent(context.Background(), &ClassifyIntentRequest{
Message: "test",
})
require.NoError(t, err)
assert.Equal(t, IntentTypeBilling, result.Intent)
assert.Equal(t, 0.5, result.Confidence)
}
func TestExtractJSON(t *testing.T) {
// markdown json block
result := extractJSON("```json\n{\"key\":\"value\"}\n```")
assert.Equal(t, `{"key":"value"}`, result)
// markdown block
result = extractJSON("```\n{\"key\":\"value\"}\n```")
assert.Equal(t, `{"key":"value"}`, result)
// plain JSON object
result = extractJSON(`{"key":"value"}`)
assert.Equal(t, `{"key":"value"}`, result)
// JSON embedded in text
result = extractJSON(`Here is the result: {"key":"value"} done`)
assert.Equal(t, `{"key":"value"}`, result)
// no JSON
result = extractJSON("no json here")
assert.Equal(t, "no json here", result)
}
func TestParseIntentFromText(t *testing.T) {
assert.Equal(t, IntentTypeQuestion, parseIntentFromText("I have a question"))
assert.Equal(t, IntentTypeComplaint, parseIntentFromText("I am very disappointed"))
assert.Equal(t, IntentTypeRequest, parseIntentFromText("can you help me"))
assert.Equal(t, IntentTypeFeedback, parseIntentFromText("I have feedback"))
assert.Equal(t, IntentTypeGreeting, parseIntentFromText("hello there"))
assert.Equal(t, IntentTypeUrgent, parseIntentFromText("urgent emergency"))
assert.Equal(t, IntentTypeCancellation, parseIntentFromText("cancel unsubscribe"))
assert.Equal(t, IntentTypeBilling, parseIntentFromText("billing payment charge"))
assert.Equal(t, IntentTypeTechnical, parseIntentFromText("there is a bug"))
assert.Equal(t, IntentTypeOther, parseIntentFromText("xyz abc 123"))
}
func TestBuildIntentSystemPrompt(t *testing.T) {
prompt := buildIntentSystemPrompt()
assert.Contains(t, prompt, "intent classifier")
assert.Contains(t, prompt, "question")
}
// ============================================================
// ToolExecutionService tests
// ============================================================
func TestToolExecutionService_GetToolsForAccount(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
// Create an enabled tool
_, err := NewCaptainCustomToolService(repo).Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Tool1", EndpointURL: "https://example.com",
})
require.NoError(t, err)
// Create a disabled tool
tool2, err := NewCaptainCustomToolService(repo).Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "Tool2", EndpointURL: "https://example.com",
})
require.NoError(t, err)
disabled := false
_, err = NewCaptainCustomToolService(repo).Update(context.Background(), tool2.ID, &UpdateCustomToolRequest{Enabled: &disabled})
require.NoError(t, err)
svc := NewToolExecutionService(repo, &mockLLMProvider3{})
defs, err := svc.GetToolsForAccount(context.Background(), 1)
require.NoError(t, err)
assert.Len(t, defs, 1) // only enabled tool
}
func TestToolExecutionService_GetToolsForAccount_Empty(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
svc := NewToolExecutionService(repo, &mockLLMProvider3{})
defs, err := svc.GetToolsForAccount(context.Background(), 1)
require.NoError(t, err)
assert.Empty(t, defs)
}
func TestToolExecutionService_ExecuteToolCall(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"result":"ok"}`))
}))
defer server.Close()
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
created, err := NewCaptainCustomToolService(repo).Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "ExecTool", EndpointURL: server.URL, HTTPMethod: "POST",
})
require.NoError(t, err)
// Ensure the tool is enabled (GORM default:true may not apply in SQLite)
created.Enabled = true
require.NoError(t, repo.Update(context.Background(), created))
svc := NewToolExecutionService(repo, &mockLLMProvider3{})
result, err := svc.ExecuteToolCall(context.Background(), 1, llm.ToolCall{
ID: "call1",
Function: llm.ToolCallFunction{
Name: created.Slug,
Arguments: `{}`,
},
})
require.NoError(t, err)
assert.Contains(t, result, "ok")
}
func TestToolExecutionService_ExecuteToolCall_NotFound(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
svc := NewToolExecutionService(repo, &mockLLMProvider3{})
_, err := svc.ExecuteToolCall(context.Background(), 1, llm.ToolCall{
Function: llm.ToolCallFunction{Name: "nonexistent"},
})
require.Error(t, err)
}
func TestToolExecutionService_ExecuteToolCall_BadArguments(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
_, err := NewCaptainCustomToolService(repo).Create(context.Background(), 1, &CreateCustomToolRequest{
Title: "BadArgs", EndpointURL: "https://example.com", HTTPMethod: "GET",
})
require.NoError(t, err)
svc := NewToolExecutionService(repo, &mockLLMProvider3{})
_, err = svc.ExecuteToolCall(context.Background(), 1, llm.ToolCall{
Function: llm.ToolCallFunction{
Name: "custom_bad_args",
Arguments: `invalid json`,
},
})
require.Error(t, err)
}
func TestToolExecutionService_RunToolCallLoop_NoTools(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
provider := &mockLLMProvider3{
chatResp: &llm.ChatResponse{
Choices: []llm.ChatChoice{
{Message: llm.ChatMessage{Role: "assistant", Content: "final answer"}},
},
},
}
// Get the created tool to find its slug
svc := NewToolExecutionService(repo, provider)
result, err := svc.RunToolCallLoop(context.Background(), 1, []llm.ChatMessage{
{Role: "user", Content: "hello"},
}, "", 0.7, 100, 5)
require.NoError(t, err)
assert.Equal(t, "final answer", result)
}
func TestToolExecutionService_RunToolCallLoop_NilProvider(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
// Get the created tool to find its slug
svc := NewToolExecutionService(repo, nil)
_, err := svc.RunToolCallLoop(context.Background(), 1, nil, "", 0.7, 100, 5)
require.Error(t, err)
}
func TestToolExecutionService_RunToolCallLoop_EmptyResponse(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
provider := &mockLLMProvider3{
chatResp: &llm.ChatResponse{Choices: []llm.ChatChoice{}},
}
// Get the created tool to find its slug
svc := NewToolExecutionService(repo, provider)
_, err := svc.RunToolCallLoop(context.Background(), 1, []llm.ChatMessage{
{Role: "user", Content: "hello"},
}, "", 0.7, 100, 5)
require.Error(t, err)
}
func TestToolExecutionService_RunToolCallLoop_LLMError(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
provider := &mockLLMProvider3{chatErr: fmt.Errorf("LLM error")}
// Get the created tool to find its slug
svc := NewToolExecutionService(repo, provider)
_, err := svc.RunToolCallLoop(context.Background(), 1, []llm.ChatMessage{
{Role: "user", Content: "hello"},
}, "", 0.7, 100, 5)
require.Error(t, err)
}
func TestToolExecutionService_RunToolCallLoop_MaxIterations(t *testing.T) {
db := newCaptainCustomToolServiceDB(t)
repo := repository.NewCaptainCustomToolRepo(db)
// LLM always returns tool_calls, never a final answer
provider := &mockLLMProvider3{
chatResp: &llm.ChatResponse{
Choices: []llm.ChatChoice{
{
Message: llm.ChatMessage{
Role: "assistant",
ToolCalls: []llm.ToolCall{{ID: "tc1", Type: "function", Function: llm.ToolCallFunction{Name: "nonexistent", Arguments: "{}"}}},
},
},
},
},
}
// Get the created tool to find its slug
svc := NewToolExecutionService(repo, provider)
_, err := svc.RunToolCallLoop(context.Background(), 1, []llm.ChatMessage{
{Role: "user", Content: "hello"},
}, "", 0.7, 100, 2)
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeded")
}
func TestCustomToolToDefinition(t *testing.T) {
tool := model.CaptainCustomTool{
Slug: "my_tool",
Description: "A tool",
ParamSchema: json.RawMessage(`{"type":"object","properties":{"x":{"type":"string"}}}`),
}
def := customToolToDefinition(tool)
assert.Equal(t, "function", def.Type)
assert.Equal(t, "my_tool", def.Function.Name)
assert.Equal(t, "A tool", def.Function.Description)
assert.NotNil(t, def.Function.Parameters)
}
func TestCustomToolToDefinition_NoSchema(t *testing.T) {
tool := model.CaptainCustomTool{
Slug: "my_tool",
}
def := customToolToDefinition(tool)
assert.NotNil(t, def.Function.Parameters)
assert.Equal(t, "object", def.Function.Parameters["type"])
}
func TestApplyToolAuth(t *testing.T) {
// Bearer
req := httptest.NewRequest("GET", "https://example.com", nil)
tool := &model.CaptainCustomTool{
AuthType: model.ToolAuthTypeBearer,
AuthConfig: json.RawMessage(`{"token":"tok"}`),
}
applyToolAuth(req, tool)
assert.Equal(t, "Bearer tok", req.Header.Get("Authorization"))
// Basic
req2 := httptest.NewRequest("GET", "https://example.com", nil)
tool2 := &model.CaptainCustomTool{
AuthType: model.ToolAuthTypeBasic,
AuthConfig: json.RawMessage(`{"username":"u","password":"p"}`),
}
applyToolAuth(req2, tool2)
assert.NotEmpty(t, req2.Header.Get("Authorization"))
// ApiKey
req3 := httptest.NewRequest("GET", "https://example.com", nil)
tool3 := &model.CaptainCustomTool{
AuthType: model.ToolAuthTypeApiKey,
AuthConfig: json.RawMessage(`{"header":"X-Key","key":"val"}`),
}
applyToolAuth(req3, tool3)
assert.Equal(t, "val", req3.Header.Get("X-Key"))
// None - no-op
req4 := httptest.NewRequest("GET", "https://example.com", nil)
tool4 := &model.CaptainCustomTool{AuthType: model.ToolAuthTypeNone}
applyToolAuth(req4, tool4)
}
func TestEscapeJSONString(t *testing.T) {
result := escapeJSONString(`hello "world" \n test`)
assert.Contains(t, result, `\"`)
assert.Contains(t, result, `\\`)
assert.Contains(t, result, `\n`)
}
// ============================================================
// CaptainDocumentCrawlBackend tests
// ============================================================
func TestCaptainDocumentCrawlBackend_Crawl_EmptyURL(t *testing.T) {
backend := NewCaptainDocumentCrawlBackend()
result, err := backend.CrawlCaptainDocument(context.Background(), &model.CaptainDocument{
ExternalLink: "",
})
require.NoError(t, err)
assert.Equal(t, "not_found", result.ErrorCode)
}
func TestCaptainDocumentCrawlBackend_Crawl_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<html><body><a href="/page1">Page1</a><a href="https://other.com/page2">Page2</a></body></html>`))
}))
defer server.Close()
backend := NewCaptainDocumentCrawlBackend()
// Override http client to use test server's client
impl := backend.(*captainDocumentCrawlBackendImpl)
impl.httpClient = server.Client()
result, err := backend.CrawlCaptainDocument(context.Background(), &model.CaptainDocument{
ExternalLink: server.URL,
})
require.NoError(t, err)
assert.Empty(t, result.ErrorCode)
assert.NotEmpty(t, result.PageLinks)
}
func TestCaptainDocumentCrawlBackend_Crawl_HTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
backend := NewCaptainDocumentCrawlBackend()
impl := backend.(*captainDocumentCrawlBackendImpl)
impl.httpClient = server.Client()
result, err := backend.CrawlCaptainDocument(context.Background(), &model.CaptainDocument{
ExternalLink: server.URL,
})
require.Error(t, err)
assert.Equal(t, "fetch_failed", result.ErrorCode)
}
func TestCaptainDocumentCrawlBackend_Crawl_ConnectionError(t *testing.T) {
backend := NewCaptainDocumentCrawlBackend()
result, err := backend.CrawlCaptainDocument(context.Background(), &model.CaptainDocument{
ExternalLink: "http://127.0.0.1:1/nonexistent",
})
require.Error(t, err)
assert.Equal(t, "fetch_failed", result.ErrorCode)
}
func TestExtractPageLinks(t *testing.T) {
htmlStr := `<html><body>
<a href="/page1">Page1</a>
<a href="https://example.com/page2">Page2</a>
<a href="#anchor">Skip</a>
<a href="">Empty</a>
<a href="/page1">Duplicate</a>
</body></html>`
links := extractPageLinks(htmlStr, "https://example.com")
assert.NotEmpty(t, links)
// Should contain absolute URLs and deduplicate
found := false
for _, l := range links {
if l == "https://example.com/page1" {
found = true
}
}
assert.True(t, found)
}
func TestExtractPageLinks_InvalidHTML(t *testing.T) {
links := extractPageLinks("not html at all", "https://example.com")
// invalid HTML is still parsed by html.Parse, just no links found
_ = links
}
func TestResolveURL(t *testing.T) {
// absolute
assert.Equal(t, "https://example.com/page", resolveURL("https://example.com/page", "https://base.com"))
// protocol-relative
assert.Equal(t, "https://example.com/page", resolveURL("//example.com/page", "https://base.com"))
// absolute path
assert.Equal(t, "https://example.com/page", resolveURL("/page", "https://example.com/base"))
// relative path
assert.Equal(t, "https://example.com/base/sub", resolveURL("sub", "https://example.com/base"))
// empty
assert.Equal(t, "", resolveURL("", "https://example.com"))
// absolute path, no subpath in base
assert.Equal(t, "https://example.com/page", resolveURL("/page", "https://example.com"))
}
// ============================================================
// CaptainDocumentPageParserBackend tests
// ============================================================
func TestCaptainDocumentPageParserBackend_Parse_EmptyURL(t *testing.T) {
backend := NewCaptainDocumentPageParserBackend()
result, err := backend.ParseCaptainDocumentPage(context.Background(), "")
require.NoError(t, err)
assert.Equal(t, "not_found", result.ErrorCode)
}
func TestCaptainDocumentPageParserBackend_Parse_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<html><head><title>Test Page</title></head><body><p>Hello World</p></body></html>`))
}))
defer server.Close()
backend := NewCaptainDocumentPageParserBackend()
impl := backend.(*captainDocumentPageParserBackendImpl)
impl.httpClient = server.Client()
result, err := backend.ParseCaptainDocumentPage(context.Background(), server.URL)
require.NoError(t, err)
assert.NotEmpty(t, result.Title) // title is the URL since <head> is skipped
assert.Contains(t, result.Content, "Hello World")
}
func TestCaptainDocumentPageParserBackend_Parse_EmptyContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<html><head><script>var x = 1;</script></head><body></body></html>`))
}))
defer server.Close()
backend := NewCaptainDocumentPageParserBackend()
impl := backend.(*captainDocumentPageParserBackendImpl)
impl.httpClient = server.Client()
result, err := backend.ParseCaptainDocumentPage(context.Background(), server.URL)
require.NoError(t, err)
assert.Equal(t, "content_empty", result.ErrorCode)
}
func TestCaptainDocumentPageParserBackend_Parse_HTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
backend := NewCaptainDocumentPageParserBackend()
impl := backend.(*captainDocumentPageParserBackendImpl)
impl.httpClient = server.Client()
result, err := backend.ParseCaptainDocumentPage(context.Background(), server.URL)
require.Error(t, err)
assert.Equal(t, "fetch_failed", result.ErrorCode)
}
func TestCaptainDocumentPageParserBackend_Parse_NoTitle(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<html><body><p>Content without title</p></body></html>`))
}))
defer server.Close()
backend := NewCaptainDocumentPageParserBackend()
impl := backend.(*captainDocumentPageParserBackendImpl)
impl.httpClient = server.Client()
result, err := backend.ParseCaptainDocumentPage(context.Background(), server.URL)
require.NoError(t, err)
assert.Equal(t, server.URL, result.Title)
assert.Contains(t, result.Content, "Content without title")
}
// ============================================================
// CaptainDocumentSyncBackend tests
// ============================================================
func TestCaptainDocumentSyncBackend_Sync_EmptyDoc(t *testing.T) {
backend := NewCaptainDocumentSyncBackend()
result, err := backend.SyncCaptainDocument(context.Background(), &model.CaptainDocument{})
require.NoError(t, err)
assert.Equal(t, "content_empty", result.ErrorCode)
}
func TestCaptainDocumentSyncBackend_Sync_WebSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<html><head><title>Doc Page</title></head><body><p>Document content here</p></body></html>`))
}))
defer server.Close()
backend := NewCaptainDocumentSyncBackend()
impl := backend.(*captainDocumentSyncBackendImpl)
impl.httpClient = server.Client()
result, err := backend.SyncCaptainDocument(context.Background(), &model.CaptainDocument{
ExternalLink: server.URL,
Name: "Web Doc",
})
require.NoError(t, err)
assert.Equal(t, "Web Doc", result.Title) // title is doc.Name since <head> is skipped
assert.Contains(t, result.Content, "Document content here")
}
func TestCaptainDocumentSyncBackend_Sync_WebEmptyContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<html><body><script>var x=1;</script></body></html>`))
}))
defer server.Close()
backend := NewCaptainDocumentSyncBackend()
impl := backend.(*captainDocumentSyncBackendImpl)
impl.httpClient = server.Client()
result, err := backend.SyncCaptainDocument(context.Background(), &model.CaptainDocument{
ExternalLink: server.URL,
Name: "Empty Doc",
})
require.NoError(t, err)
assert.Equal(t, "content_empty", result.ErrorCode)
}
func TestCaptainDocumentSyncBackend_Sync_WebHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
backend := NewCaptainDocumentSyncBackend()
impl := backend.(*captainDocumentSyncBackendImpl)
impl.httpClient = server.Client()
result, err := backend.SyncCaptainDocument(context.Background(), &model.CaptainDocument{
ExternalLink: server.URL,
})
require.Error(t, err)
assert.Equal(t, "fetch_failed", result.ErrorCode)
}
func TestCaptainDocumentSyncBackend_Sync_PDFNotFound(t *testing.T) {
backend := NewCaptainDocumentSyncBackend()
result, err := backend.SyncCaptainDocument(context.Background(), &model.CaptainDocument{
ContentType: "application/pdf",
FileURL: "",
})
require.NoError(t, err)
assert.Equal(t, "not_found", result.ErrorCode)
}
func TestCaptainDocumentSyncBackend_Sync_PDFNonExistentFile(t *testing.T) {
backend := NewCaptainDocumentSyncBackend()
result, err := backend.SyncCaptainDocument(context.Background(), &model.CaptainDocument{
ContentType: "application/pdf",
FileURL: "/nonexistent/path/file.pdf",
})
// resolvePDFPath returns "" for non-existent files
require.NoError(t, err)
assert.Equal(t, "not_found", result.ErrorCode)
}
func TestCaptainDocumentSyncBackend_ResolvePDFPath(t *testing.T) {
backend := NewCaptainDocumentSyncBackend()
impl := backend.(*captainDocumentSyncBackendImpl)
// empty
assert.Equal(t, "", impl.resolvePDFPath(&model.CaptainDocument{FileURL: ""}))
// /uploads/ path
assert.Equal(t, "./uploads/captain_docs/1/file.pdf",
impl.resolvePDFPath(&model.CaptainDocument{FileURL: "/uploads/captain_docs/1/file.pdf"}))
}
func TestExtractHTMLText(t *testing.T) {
_, content := extractHTMLText(`<html><head><title>My Title</title><script>var x=1;</script></head><body><p>Hello</p><p>World</p></body></html>`)
// title is inside <head> which is skipped by the extractor
assert.Contains(t, content, "Hello")
assert.Contains(t, content, "World")
assert.NotContains(t, content, "var x=1")
}
func TestExtractHTMLText_NoTitle(t *testing.T) {
_, content := extractHTMLText(`<html><body><p>Just content</p></body></html>`)
assert.Contains(t, content, "Just content")
}
func TestExtractHTMLText_InvalidHTML(t *testing.T) {
title, content := extractHTMLText("just plain text")
assert.Equal(t, "", title)
assert.Contains(t, content, "just plain text")
}
func TestExtractHTMLText_EmptyString(t *testing.T) {
title, content := extractHTMLText("")
assert.Equal(t, "", title)
assert.Equal(t, "", content)
}
// ============================================================
// FeatureFlagStringEnabled tests
// ============================================================
func TestFeatureFlagStringEnabled(t *testing.T) {
// JSON object format
assert.True(t, featureFlagStringEnabled(`{"custom_tools":true}`, "custom_tools"))
assert.False(t, featureFlagStringEnabled(`{"custom_tools":false}`, "custom_tools"))
// JSON array format
assert.True(t, featureFlagStringEnabled(`["custom_tools","other"]`, "custom_tools"))
assert.False(t, featureFlagStringEnabled(`["other"]`, "custom_tools"))
// Comma-separated format
assert.True(t, featureFlagStringEnabled("custom_tools,other", "custom_tools"))
assert.False(t, featureFlagStringEnabled("other", "custom_tools"))
// empty
assert.False(t, featureFlagStringEnabled("", "custom_tools"))
assert.False(t, featureFlagStringEnabled(" ", "custom_tools"))
}