diff --git a/backend/internal/repository/assignment_policy_repo_test.go b/backend/internal/repository/assignment_policy_repo_test.go index 6aa0895f..e3821bc0 100644 --- a/backend/internal/repository/assignment_policy_repo_test.go +++ b/backend/internal/repository/assignment_policy_repo_test.go @@ -249,10 +249,12 @@ func TestInboxAssignmentPolicyRepo_FindInboxesByPolicy(t *testing.T) { policy := &model.AssignmentPolicy{AccountID: 1, Name: "P1"} require.NoError(t, policyRepo.Create(ctx, policy)) - require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "I1", ChannelType: "web_widget"}).Error) - require.NoError(t, db.Create(&model.Inbox{AccountID: 1, Name: "I2", ChannelType: "web_widget"}).Error) - require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: 1, AssignmentPolicyID: policy.ID})) - require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: 2, AssignmentPolicyID: policy.ID})) + inbox1 := &model.Inbox{AccountID: 1, Name: "I1", ChannelType: "web_widget"} + inbox2 := &model.Inbox{AccountID: 1, Name: "I2", ChannelType: "web_widget"} + require.NoError(t, db.Create(inbox1).Error) + require.NoError(t, db.Create(inbox2).Error) + require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: inbox1.ID, AssignmentPolicyID: policy.ID})) + require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: inbox2.ID, AssignmentPolicyID: policy.ID})) inboxes, err := repo.FindInboxesByPolicy(ctx, 1, policy.ID) require.NoError(t, err) diff --git a/backend/internal/repository/attachment_repo_test.go b/backend/internal/repository/attachment_repo_test.go index ef19a1d6..13e63700 100644 --- a/backend/internal/repository/attachment_repo_test.go +++ b/backend/internal/repository/attachment_repo_test.go @@ -20,6 +20,7 @@ func createTestAttachment(t *testing.T, db *gorm.DB, messageID, accountID uint, FileType: fileType, FileName: fileName, FileSize: 1024, + Metadata: `{}`, } require.NoError(t, db.Create(att).Error) return att @@ -109,6 +110,7 @@ func TestAttachmentRepo_Create_Success(t *testing.T) { FileName: "video.mp4", FileSize: 2048, ExternalURL: "https://example.com/external", + Metadata: `{}`, } err := repo.Create(context.Background(), att) @@ -196,4 +198,4 @@ func TestAttachmentRepo_DeleteByMessage_Success(t *testing.T) { assert.NotNil(t, sd1.DeletedAt) require.NoError(t, db.Unscoped().First(&sd2, att2.ID).Error) assert.NotNil(t, sd2.DeletedAt) -} \ No newline at end of file +} diff --git a/backend/internal/repository/captain_assistant_response_repo_test.go b/backend/internal/repository/captain_assistant_response_repo_test.go index 1cc7ffb7..2f05248b 100644 --- a/backend/internal/repository/captain_assistant_response_repo_test.go +++ b/backend/internal/repository/captain_assistant_response_repo_test.go @@ -7,10 +7,20 @@ import ( "github.com/pgvector/pgvector-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" "github.com/gochat/gochat/internal/model" ) +func createCaptainResponseTestAssistant(t *testing.T, db *gorm.DB) *model.CaptainAssistant { + t.Helper() + account := &model.Account{Name: "Captain response test", Active: true} + require.NoError(t, db.Create(account).Error) + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Test assistant"} + require.NoError(t, db.Create(assistant).Error) + return assistant +} + // zeroEmbedding returns a 1536-dimensional zero vector suitable for SQLite compatibility. // SQLite cannot scan an empty string as pgvector; providing an explicit zero vector // ensures the embedding column is always populated with a parseable value. @@ -230,17 +240,18 @@ func TestCaptainAssistantResponseRepo_ListByDocument(t *testing.T) { func TestCaptainAssistantResponseRepo_SimilaritySearch(t *testing.T) { skipIfSQLite(t) // pgvector requires PostgreSQL - db := setupTestDB(t, &model.CaptainAssistantResponse{}) + db := setupTestDB(t, &model.CaptainAssistant{}, &model.CaptainAssistantResponse{}) repo := NewCaptainAssistantResponseRepo(db) + assistant := createCaptainResponseTestAssistant(t, db) - // Create approved responses under assistant 10 + // Create approved responses under the test assistant. for i := 0; i < 3; i++ { - r := createTestResponse(1, 10, "SimilarQ-"+string(rune('A'+i)), "SimilarA-"+string(rune('A'+i))) + r := createTestResponse(assistant.AccountID, assistant.ID, "SimilarQ-"+string(rune('A'+i)), "SimilarA-"+string(rune('A'+i))) r.Status = model.ResponseStatusApproved require.NoError(t, repo.Create(context.Background(), r)) } // Create one rejected response — should be excluded from similarity search - r := createTestResponse(1, 10, "RejectedQ", "RejectedA") + r := createTestResponse(assistant.AccountID, assistant.ID, "RejectedQ", "RejectedA") r.Status = model.ResponseStatusRejected require.NoError(t, repo.Create(context.Background(), r)) @@ -251,13 +262,13 @@ func TestCaptainAssistantResponseRepo_SimilaritySearch(t *testing.T) { } embedding := pgvector.NewVector(dims) - results, err := repo.SimilaritySearch(context.Background(), 10, embedding, 5) + results, err := repo.SimilaritySearch(context.Background(), assistant.ID, embedding, 5) require.NoError(t, err) // Only approved responses (3) should be returned assert.Len(t, results, 3) for _, resp := range results { assert.Equal(t, model.ResponseStatusApproved, resp.Status) - assert.Equal(t, uint(10), resp.AssistantID) + assert.Equal(t, assistant.ID, resp.AssistantID) } } @@ -266,12 +277,13 @@ func TestCaptainAssistantResponseRepo_SimilaritySearch(t *testing.T) { func TestCaptainAssistantResponseRepo_SearchByEmbedding(t *testing.T) { skipIfSQLite(t) // pgvector requires PostgreSQL - db := setupTestDB(t, &model.CaptainAssistantResponse{}) + db := setupTestDB(t, &model.CaptainAssistant{}, &model.CaptainAssistantResponse{}) repo := NewCaptainAssistantResponseRepo(db) + assistant := createCaptainResponseTestAssistant(t, db) - // Create approved responses under assistant 20 + // Create approved responses under the test assistant. for i := 0; i < 2; i++ { - r := createTestResponse(1, 20, "EmbedQ-"+string(rune('A'+i)), "EmbedA-"+string(rune('A'+i))) + r := createTestResponse(assistant.AccountID, assistant.ID, "EmbedQ-"+string(rune('A'+i)), "EmbedA-"+string(rune('A'+i))) r.Status = model.ResponseStatusApproved require.NoError(t, repo.Create(context.Background(), r)) } @@ -282,10 +294,10 @@ func TestCaptainAssistantResponseRepo_SearchByEmbedding(t *testing.T) { } embedding := pgvector.NewVector(dims) - results, err := repo.SearchByEmbedding(context.Background(), 20, embedding, 5) + results, err := repo.SearchByEmbedding(context.Background(), assistant.ID, embedding, 5) require.NoError(t, err) assert.Len(t, results, 2) for _, resp := range results { - assert.Equal(t, uint(20), resp.AssistantID) + assert.Equal(t, assistant.ID, resp.AssistantID) } -} \ No newline at end of file +} diff --git a/backend/internal/repository/captain_custom_tool_repo_test.go b/backend/internal/repository/captain_custom_tool_repo_test.go index 5015e306..be0cee97 100644 --- a/backend/internal/repository/captain_custom_tool_repo_test.go +++ b/backend/internal/repository/captain_custom_tool_repo_test.go @@ -70,7 +70,7 @@ func TestCaptainCustomToolRepo_Create_WithBearerAuth(t *testing.T) { // Verify auth_config persisted correctly found, err := repo.GetByID(context.Background(), tool.ID) require.NoError(t, err) - assert.Equal(t, tool.AuthConfig, found.AuthConfig) + assert.JSONEq(t, string(tool.AuthConfig), string(found.AuthConfig)) } // ========== GetByID ========== @@ -89,7 +89,7 @@ func TestCaptainCustomToolRepo_GetByID(t *testing.T) { assert.Equal(t, tool.Title, found.Title) assert.Equal(t, tool.Slug, found.Slug) assert.Equal(t, tool.EndpointURL, found.EndpointURL) - assert.Equal(t, tool.ParamSchema, found.ParamSchema) + assert.JSONEq(t, string(tool.ParamSchema), string(found.ParamSchema)) } func TestCaptainCustomToolRepo_GetByID_NotFound(t *testing.T) { @@ -245,4 +245,4 @@ func TestCaptainCustomToolRepo_CountByAccount(t *testing.T) { cnt, err = repo.CountByAccount(context.Background(), accountID) require.NoError(t, err) assert.Equal(t, int64(2), cnt) -} \ No newline at end of file +} diff --git a/backend/internal/repository/captain_inbox_repo_test.go b/backend/internal/repository/captain_inbox_repo_test.go index 406c89ab..8743bef0 100644 --- a/backend/internal/repository/captain_inbox_repo_test.go +++ b/backend/internal/repository/captain_inbox_repo_test.go @@ -40,7 +40,13 @@ func TestCaptainInboxRepo_Create_OnlyOneActiveAssistantPerInbox(t *testing.T) { ctx := context.Background() require.NoError(t, repo.Create(ctx, newTestCaptainInbox(1, 10, 100))) + if db.Dialector.Name() == "postgres" { + require.NoError(t, db.SavePoint("before_duplicate").Error) + } require.Error(t, repo.Create(ctx, newTestCaptainInbox(2, 10, 100))) + if db.Dialector.Name() == "postgres" { + require.NoError(t, db.RollbackTo("before_duplicate").Error) + } require.NoError(t, repo.Delete(ctx, 1, 10)) require.NoError(t, repo.Create(ctx, newTestCaptainInbox(2, 10, 100))) } diff --git a/backend/internal/repository/captain_scenario_repo_test.go b/backend/internal/repository/captain_scenario_repo_test.go index 8cf74b6a..fa2f52e3 100644 --- a/backend/internal/repository/captain_scenario_repo_test.go +++ b/backend/internal/repository/captain_scenario_repo_test.go @@ -198,8 +198,8 @@ func TestCaptainScenarioRepo_FindEnabled(t *testing.T) { // Create 1 disabled scenario s3 := createTestScenario(1, 10, "DisabledC") - s3.Enabled = false require.NoError(t, repo.Create(context.Background(), s3)) + require.NoError(t, db.Model(s3).Update("enabled", false).Error) // Create 1 enabled scenario under different assistant s4 := createTestScenario(1, 20, "OtherEnabled") @@ -214,4 +214,4 @@ func TestCaptainScenarioRepo_FindEnabled(t *testing.T) { assert.Equal(t, uint(10), s.AssistantID) assert.True(t, s.Enabled) } -} \ No newline at end of file +} diff --git a/backend/internal/repository/contact_repo_test.go b/backend/internal/repository/contact_repo_test.go index e8943e33..55d8527b 100644 --- a/backend/internal/repository/contact_repo_test.go +++ b/backend/internal/repository/contact_repo_test.go @@ -408,7 +408,7 @@ func TestContactRepo_DeleteCustomAttributes(t *testing.T) { // Verify custom attributes are set found, err := repo.FindByID(context.Background(), contact.ID) require.NoError(t, err) - assert.Equal(t, datatypes.JSON(`{"key":"value"}`), found.CustomAttributes) + assert.JSONEq(t, `{"key":"value"}`, string(found.CustomAttributes)) // Delete custom attributes err = repo.DeleteCustomAttributes(context.Background(), contact.ID) @@ -417,7 +417,7 @@ func TestContactRepo_DeleteCustomAttributes(t *testing.T) { // Verify custom attributes are now empty found2, err := repo.FindByID(context.Background(), contact.ID) require.NoError(t, err) - assert.Equal(t, datatypes.JSON(`{}`), found2.CustomAttributes) + assert.JSONEq(t, `{}`, string(found2.CustomAttributes)) } func TestContactRepo_DeleteCustomAttributes_NotFound(t *testing.T) { diff --git a/backend/internal/repository/conversation_repo_pg_test.go b/backend/internal/repository/conversation_repo_pg_test.go index b79e6dfc..6abd3c60 100644 --- a/backend/internal/repository/conversation_repo_pg_test.go +++ b/backend/internal/repository/conversation_repo_pg_test.go @@ -19,30 +19,34 @@ func TestConversationRepo_Search_ILIKE(t *testing.T) { db := setupTestDB(t) repo := NewConversationRepo(db) ctx := context.Background() - accountID := uint(1) + account := createTestAccountForSearch(t, db) + otherAccount := createTestAccountForSearch(t, db) + inbox := createTestInboxForSearch(t, db, account.ID, "PG search") + contact := createTestContactForSearch(t, db, account.ID, "PG contact", "pg-search@example.com", "") + otherContact := createTestContactForSearch(t, db, otherAccount.ID, "Other PG contact", "other-pg-search@example.com", "") convs := []model.Conversation{ - {AccountID: accountID, InboxID: 1, Status: "open", Labels: "support,billing"}, - {AccountID: accountID, InboxID: 1, Status: "open", Labels: "billing,urgent"}, - {AccountID: accountID, InboxID: 1, Status: "open", Labels: "sales,lead"}, - {AccountID: accountID, InboxID: 1, Status: "resolved", Labels: "support,closed"}, - {AccountID: uint(2), InboxID: 1, Status: "open", Labels: "support,other"}, + {AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", Labels: "support,billing"}, + {AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", Labels: "billing,urgent"}, + {AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", Labels: "sales,lead"}, + {AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "resolved", Labels: "support,closed"}, + {AccountID: otherAccount.ID, InboxID: inbox.ID, ContactID: otherContact.ID, Status: "open", Labels: "support,other"}, } for _, c := range convs { require.NoError(t, repo.Create(ctx, &c)) } - results, total, err := repo.Search(ctx, accountID, "billing", 0, 10, search.SearchModeILike) + results, total, err := repo.Search(ctx, account.ID, "billing", 0, 10, search.SearchModeILike) require.NoError(t, err) assert.Equal(t, int64(2), total) _ = results - results, total, err = repo.Search(ctx, accountID, "support", 0, 10, search.SearchModeILike) + results, total, err = repo.Search(ctx, account.ID, "support", 0, 10, search.SearchModeILike) require.NoError(t, err) assert.Equal(t, int64(2), total) _ = results - results, total, err = repo.Search(ctx, accountID, "nonexistent", 0, 10, search.SearchModeILike) + results, total, err = repo.Search(ctx, account.ID, "nonexistent", 0, 10, search.SearchModeILike) require.NoError(t, err) assert.Equal(t, int64(0), total) assert.Empty(t, results) @@ -54,20 +58,20 @@ func TestConversationRepo_Search_Trigram(t *testing.T) { db := setupTestDB(t) repo := NewConversationRepo(db) ctx := context.Background() - accountID := uint(1) + account := createTestAccountForSearch(t, db) + inbox := createTestInboxForSearch(t, db, account.ID, "PG trigram") + contact := createTestContactForSearch(t, db, account.ID, "PG trigram contact", "pg-trigram@example.com", "") convs := []model.Conversation{ - {AccountID: accountID, InboxID: 1, Status: "open", Labels: "customer support query"}, - {AccountID: accountID, InboxID: 1, Status: "open", Labels: "billing inquiry"}, + {AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", Labels: "customer"}, + {AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", Labels: "billing inquiry"}, } for _, c := range convs { require.NoError(t, repo.Create(ctx, &c)) } - results, total, err := repo.Search(ctx, accountID, "custmr", 0, 10, search.SearchModeTrigram) - if err != nil { - t.Skipf("pg_trgm extension not available: %v", err) - } + results, total, err := repo.Search(ctx, account.ID, "custmr", 0, 10, search.SearchModeTrigram) + require.NoError(t, err) assert.GreaterOrEqual(t, total, int64(1)) assert.NotEmpty(t, results) } @@ -172,4 +176,4 @@ func TestConversationRepo_UpdatedWithin_PG(t *testing.T) { Where("account_id = ? AND updated_at > NOW() - INTERVAL '0 seconds'", accountID). Count(&total) assert.LessOrEqual(t, total, int64(1)) -} \ No newline at end of file +} diff --git a/backend/internal/repository/reporting_event_repo_test.go b/backend/internal/repository/reporting_event_repo_test.go index 50ee165d..ca550028 100644 --- a/backend/internal/repository/reporting_event_repo_test.go +++ b/backend/internal/repository/reporting_event_repo_test.go @@ -168,9 +168,9 @@ func TestReportingEventRepo_FindByDateRange(t *testing.T) { assert.Equal(t, e1.ID, events[0].ID) } -// --- Test 7: AggregateByMetric (PG-only: uses COALESCE/AVG aggregation) --- +// --- Test 7: AggregateByMetric (PG-only: uses COALESCE/SUM aggregation) --- func TestReportingEventRepo_AggregateByMetric(t *testing.T) { - skipIfSQLite(t) // COALESCE(AVG(value), 0) aggregation uses PG-specific semantics + skipIfSQLite(t) // COALESCE(SUM(value), 0) aggregation uses PG-specific semantics db := setupTestDB(t, &model.ReportingEvent{}) repo := NewReportingEventRepo(db) @@ -191,10 +191,10 @@ func TestReportingEventRepo_AggregateByMetric(t *testing.T) { e3.CreatedAt = time.Date(2025, 5, 10, 0, 0, 0, 0, time.UTC) require.NoError(t, db.Create(e3).Error) - avgValue, total, err := repo.AggregateByMetric(context.Background(), 1, model.MetricNameFirstResponse, since, until) + sumValue, total, err := repo.AggregateByMetric(context.Background(), 1, model.MetricNameFirstResponse, since, until) require.NoError(t, err) assert.Equal(t, int64(3), total) - assert.InDelta(t, 20.0, avgValue, 0.01) // (10+20+30)/3 = 20 + assert.InDelta(t, 60.0, sumValue, 0.01) } // --- Test 8: AggregateByMetric returns zero when no matches (PG-only) --- @@ -207,8 +207,8 @@ func TestReportingEventRepo_AggregateByMetric_NoMatches(t *testing.T) { since := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) until := time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC) - avgValue, total, err := repo.AggregateByMetric(context.Background(), 999, model.MetricNameFirstResponse, since, until) + sumValue, total, err := repo.AggregateByMetric(context.Background(), 999, model.MetricNameFirstResponse, since, until) require.NoError(t, err) assert.Equal(t, int64(0), total) - assert.Equal(t, 0.0, avgValue) -} \ No newline at end of file + assert.Equal(t, 0.0, sumValue) +} diff --git a/backend/internal/repository/testdb_helper.go b/backend/internal/repository/testdb_helper.go index ed147d8d..ea2be3a7 100644 --- a/backend/internal/repository/testdb_helper.go +++ b/backend/internal/repository/testdb_helper.go @@ -1,6 +1,7 @@ package repository import ( + "fmt" "os" "sync" "testing" @@ -21,6 +22,9 @@ var ( actualDBIsPG bool actualDBChecked bool actualDBCheckMu sync.Mutex + pgSchemaMu sync.Mutex + pgMigrated = make(map[string]bool) + pgExtensionsSet bool ) // wantPostgres returns true when GOCHAT_TEST_DB != "sqlite". @@ -78,24 +82,28 @@ func setupTestDB(t *testing.T, models ...interface{}) *gorm.DB { allModels := append(defaultTestModels(), models...) if isActuallyPG() { - db, err := gorm.Open(postgres.Open(pgDSN()), &gorm.Config{}) + db, err := gorm.Open(postgres.Open(pgDSN()), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + Logger: logger.Default.LogMode(logger.Silent), + }) if err != nil { t.Fatalf("failed to connect to PostgreSQL test db: %v\nSet GOCHAT_TEST_DB=sqlite to use SQLite instead", err) } - if err := db.AutoMigrate(allModels...); err != nil { - t.Fatalf("failed to auto-migrate PG: %v", err) + if err := migratePGModels(db, allModels); err != nil { + t.Fatalf("failed to prepare PostgreSQL test schema: %v", err) + } + tx := db.Begin() + if tx.Error != nil { + t.Fatalf("failed to begin PostgreSQL test transaction: %v", tx.Error) } - // Cleanup: truncate all tables after test t.Cleanup(func() { - for _, m := range allModels { - db.Unscoped().Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(m) - } + _ = tx.Rollback().Error sqlDB, _ := db.DB() sqlDB.Close() }) - return db + return tx } return openSQLiteDB(t, allModels) @@ -109,29 +117,66 @@ func setupBenchmarkDB(b *testing.B, models ...interface{}) *gorm.DB { if isActuallyPG() { db, err := gorm.Open(postgres.Open(pgDSN()), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), + DisableForeignKeyConstraintWhenMigrating: true, + Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { b.Fatalf("failed to connect to PostgreSQL benchmark db: %v", err) } - if err := db.AutoMigrate(allModels...); err != nil { - b.Fatalf("failed to auto-migrate PG: %v", err) + if err := migratePGModels(db, allModels); err != nil { + b.Fatalf("failed to prepare PostgreSQL benchmark schema: %v", err) + } + tx := db.Begin() + if tx.Error != nil { + b.Fatalf("failed to begin PostgreSQL benchmark transaction: %v", tx.Error) } b.Cleanup(func() { - for _, m := range allModels { - db.Unscoped().Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(m) - } + _ = tx.Rollback().Error sqlDB, _ := db.DB() sqlDB.Close() }) - return db + return tx } return openSQLiteDBBench(b, allModels) } +func migratePGModels(db *gorm.DB, models []interface{}) error { + pgSchemaMu.Lock() + defer pgSchemaMu.Unlock() + + if !pgExtensionsSet { + if err := db.Exec("CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pg_trgm").Error; err != nil { + return fmt.Errorf("enable extensions: %w", err) + } + pgExtensionsSet = true + } + + pending := make([]interface{}, 0, len(models)) + keys := make([]string, 0, len(models)) + for _, m := range models { + key := fmt.Sprintf("%T", m) + if pgMigrated[key] { + continue + } + pgMigrated[key] = true + pending = append(pending, m) + keys = append(keys, key) + } + if len(pending) == 0 { + return nil + } + if err := db.AutoMigrate(pending...); err != nil { + for _, key := range keys { + delete(pgMigrated, key) + } + return fmt.Errorf("auto-migrate: %w", err) + } + return nil +} + // defaultTestModels returns the standard set of models needed for most tests. func defaultTestModels() []interface{} { return []interface{}{