Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package canned
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
// testDBProvider implements DBProvider for tests.
|
||||
type testDBProvider struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (p *testDBProvider) DB() *gorm.DB {
|
||||
return p.db
|
||||
}
|
||||
|
||||
// setupCannedTestDB creates an SQLite in-memory database with all required migrations.
|
||||
func setupCannedTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open SQLite test database: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.User{},
|
||||
&model.AccountUser{},
|
||||
&CannedResponse{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to auto-migrate models: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// setupCannedTestDBProvider creates a DBProvider-backed test database.
|
||||
func setupCannedTestDBProvider(t *testing.T) DBProvider {
|
||||
t.Helper()
|
||||
db := setupCannedTestDB(t)
|
||||
return &testDBProvider{db: db}
|
||||
}
|
||||
|
||||
// seedTestAccount creates a minimal account + user for canned response tests.
|
||||
func seedTestAccount(db *gorm.DB, t *testing.T) (accountID uint, userID uint) {
|
||||
t.Helper()
|
||||
|
||||
account := &model.Account{Name: "Test Account"}
|
||||
if err := db.Create(account).Error; err != nil {
|
||||
t.Fatalf("failed to create test account: %v", err)
|
||||
}
|
||||
accountID = account.ID
|
||||
|
||||
user := &model.User{
|
||||
AccountID: accountID,
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
Password: "testpassword",
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
userID = user.ID
|
||||
|
||||
au := &model.AccountUser{
|
||||
AccountID: accountID,
|
||||
UserID: userID,
|
||||
Role: "administrator",
|
||||
}
|
||||
if err := db.Create(au).Error; err != nil {
|
||||
t.Fatalf("failed to create account_user: %v", err)
|
||||
}
|
||||
|
||||
return accountID, userID
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package canned
|
||||
|
||||
import (
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
// CannedResponse represents a pre-written message template for quick replies.
|
||||
// Reference: Chatwoot CannedResponse model — account_id, content (text),
|
||||
// short_code (string, unique scope account_id).
|
||||
// Has order_by_search scope that ranks by short_code match then content match.
|
||||
type CannedResponse struct {
|
||||
model.Base
|
||||
AccountID uint `gorm:"index;not null;uniqueIndex:idx_canned_short_code" json:"account_id"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
ShortCode string `gorm:"size:255;not null;uniqueIndex:idx_canned_short_code" json:"short_code"`
|
||||
}
|
||||
|
||||
func (CannedResponse) TableName() string { return "canned_responses" }
|
||||
@@ -0,0 +1,123 @@
|
||||
package canned
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DBProvider provides database access — implemented by app.App to break the import cycle.
|
||||
type DBProvider interface {
|
||||
DB() *gorm.DB
|
||||
}
|
||||
|
||||
// CannedResponseService provides CRUD and search for canned responses.
|
||||
// Reference: Chatwoot CannedResponse — CRUD + order_by_search scope
|
||||
// (ranks by short_code match then content match).
|
||||
type CannedResponseService struct {
|
||||
db DBProvider
|
||||
}
|
||||
|
||||
// NewCannedResponseService creates a new CannedResponseService.
|
||||
func NewCannedResponseService(db DBProvider) *CannedResponseService {
|
||||
return &CannedResponseService{db: db}
|
||||
}
|
||||
|
||||
// Create creates a new canned response.
|
||||
func (s *CannedResponseService) Create(ctx context.Context, response *CannedResponse) error {
|
||||
return s.db.DB().WithContext(ctx).Create(response).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves a canned response by ID.
|
||||
func (s *CannedResponseService) GetByID(ctx context.Context, id uint) (*CannedResponse, error) {
|
||||
var resp CannedResponse
|
||||
if err := s.db.DB().WithContext(ctx).First(&resp, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetByAccountAndID retrieves a canned response scoped through its account.
|
||||
func (s *CannedResponseService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*CannedResponse, error) {
|
||||
var resp CannedResponse
|
||||
if err := s.db.DB().WithContext(ctx).
|
||||
Where("account_id = ? AND id = ?", accountID, id).
|
||||
First(&resp).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Update updates a canned response.
|
||||
func (s *CannedResponseService) Update(ctx context.Context, id uint, updates map[string]interface{}) error {
|
||||
return s.db.DB().WithContext(ctx).Model(&CannedResponse{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// UpdateByAccountAndID updates a canned response scoped through its account.
|
||||
func (s *CannedResponseService) UpdateByAccountAndID(ctx context.Context, accountID, id uint, updates map[string]interface{}) error {
|
||||
resp, err := s.GetByAccountAndID(ctx, accountID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.DB().WithContext(ctx).Model(resp).Updates(updates).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes a canned response.
|
||||
func (s *CannedResponseService) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.DB().WithContext(ctx).Delete(&CannedResponse{}, id).Error
|
||||
}
|
||||
|
||||
// DeleteByAccountAndID deletes a canned response scoped through its account.
|
||||
func (s *CannedResponseService) DeleteByAccountAndID(ctx context.Context, accountID, id uint) error {
|
||||
resp, err := s.GetByAccountAndID(ctx, accountID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.DB().WithContext(ctx).Unscoped().Delete(resp).Error
|
||||
}
|
||||
|
||||
// ListByAccount returns all canned responses for an account.
|
||||
func (s *CannedResponseService) ListByAccount(ctx context.Context, accountID uint) ([]CannedResponse, error) {
|
||||
var responses []CannedResponse
|
||||
err := s.db.DB().WithContext(ctx).
|
||||
Where("account_id = ?", accountID).
|
||||
Order("short_code ASC").
|
||||
Find(&responses).Error
|
||||
return responses, err
|
||||
}
|
||||
|
||||
// Search performs a ranked search over canned responses.
|
||||
// Reference: Chatwoot order_by_search scope — ranks by short_code match then content match.
|
||||
// Results are ordered so that short_code matches appear first, then content matches.
|
||||
func (s *CannedResponseService) Search(ctx context.Context, accountID uint, query string) ([]CannedResponse, error) {
|
||||
if query == "" {
|
||||
return s.ListByAccount(ctx, accountID)
|
||||
}
|
||||
|
||||
var responses []CannedResponse
|
||||
// Use a CASE-based ordering: short_code matches rank higher than content matches.
|
||||
// This mirrors Chatwoot's order_by_search scope.
|
||||
// The search is case-insensitive.
|
||||
lowerQuery := strings.ToLower(query)
|
||||
prefixPattern := lowerQuery + "%"
|
||||
likePattern := "%" + lowerQuery + "%"
|
||||
err := s.db.DB().WithContext(ctx).
|
||||
Select("canned_responses.*, CASE WHEN LOWER(short_code) LIKE ? THEN 1.0 WHEN LOWER(short_code) LIKE ? THEN 0.5 WHEN LOWER(content) LIKE ? THEN 0.2 ELSE 0 END AS search_rank", prefixPattern, likePattern, likePattern).
|
||||
Where("account_id = ? AND (LOWER(short_code) LIKE ? OR LOWER(content) LIKE ?)", accountID, likePattern, likePattern).
|
||||
Order("search_rank DESC").
|
||||
Order("short_code ASC").
|
||||
Find(&responses).Error
|
||||
return responses, err
|
||||
}
|
||||
|
||||
// GetByShortCode retrieves a canned response by its short code within an account.
|
||||
func (s *CannedResponseService) GetByShortCode(ctx context.Context, accountID uint, shortCode string) (*CannedResponse, error) {
|
||||
var resp CannedResponse
|
||||
if err := s.db.DB().WithContext(ctx).
|
||||
Where("account_id = ? AND short_code = ?", accountID, shortCode).
|
||||
First(&resp).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package canned
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
func TestCannedResponseService_Create(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
cr := &CannedResponse{
|
||||
AccountID: accountID,
|
||||
ShortCode: "greeting",
|
||||
Content: "Hello! How can I help you?",
|
||||
}
|
||||
|
||||
if err := svc.Create(context.Background(), cr); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
if cr.ID == 0 {
|
||||
t.Fatal("expected ID to be set after Create")
|
||||
}
|
||||
if cr.ShortCode != "greeting" {
|
||||
t.Fatalf("expected ShortCode 'greeting', got '%s'", cr.ShortCode)
|
||||
}
|
||||
if cr.Content != "Hello! How can I help you?" {
|
||||
t.Fatalf("expected Content 'Hello! How can I help you?', got '%s'", cr.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_Create_DuplicateShortCode(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
cr1 := &CannedResponse{
|
||||
AccountID: accountID,
|
||||
ShortCode: "greeting",
|
||||
Content: "Hello!",
|
||||
}
|
||||
if err := svc.Create(context.Background(), cr1); err != nil {
|
||||
t.Fatalf("first Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Same short_code within the same account should fail due to uniqueIndex.
|
||||
cr2 := &CannedResponse{
|
||||
AccountID: accountID,
|
||||
ShortCode: "greeting",
|
||||
Content: "Hi there!",
|
||||
}
|
||||
if err := svc.Create(context.Background(), cr2); err == nil {
|
||||
t.Fatal("expected error on duplicate short_code, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_GetByID(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
cr := &CannedResponse{
|
||||
AccountID: accountID,
|
||||
ShortCode: "thanks",
|
||||
Content: "Thank you for contacting us!",
|
||||
}
|
||||
if err := svc.Create(context.Background(), cr); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
found, err := svc.GetByID(context.Background(), cr.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID failed: %v", err)
|
||||
}
|
||||
if found.ID != cr.ID {
|
||||
t.Fatalf("expected ID %d, got %d", cr.ID, found.ID)
|
||||
}
|
||||
if found.ShortCode != "thanks" {
|
||||
t.Fatalf("expected ShortCode 'thanks', got '%s'", found.ShortCode)
|
||||
}
|
||||
if found.Content != "Thank you for contacting us!" {
|
||||
t.Fatalf("expected Content 'Thank you for contacting us!', got '%s'", found.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_GetByID_NotFound(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
_, err := svc.GetByID(context.Background(), 9999)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent ID, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_GetByAccountAndID(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
otherAccount := &model.Account{Name: "Other Scoped Account"}
|
||||
if err := provider.DB().Create(otherAccount).Error; err != nil {
|
||||
t.Fatalf("failed to create other account: %v", err)
|
||||
}
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
cr := &CannedResponse{AccountID: accountID, ShortCode: "scoped", Content: "Scoped content"}
|
||||
if err := svc.Create(context.Background(), cr); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
found, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByAccountAndID failed: %v", err)
|
||||
}
|
||||
if found.ID != cr.ID {
|
||||
t.Fatalf("expected ID %d, got %d", cr.ID, found.ID)
|
||||
}
|
||||
|
||||
if _, err := svc.GetByAccountAndID(context.Background(), otherAccount.ID, cr.ID); err == nil {
|
||||
t.Fatal("expected cross-account lookup to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_Update(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
cr := &CannedResponse{
|
||||
AccountID: accountID,
|
||||
ShortCode: "bye",
|
||||
Content: "Goodbye!",
|
||||
}
|
||||
if err := svc.Create(context.Background(), cr); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"short_code": "farewell",
|
||||
"content": "Farewell and thank you!",
|
||||
}
|
||||
if err := svc.Update(context.Background(), cr.ID, updates); err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
found, err := svc.GetByID(context.Background(), cr.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID after Update failed: %v", err)
|
||||
}
|
||||
if found.ShortCode != "farewell" {
|
||||
t.Fatalf("expected ShortCode 'farewell', got '%s'", found.ShortCode)
|
||||
}
|
||||
if found.Content != "Farewell and thank you!" {
|
||||
t.Fatalf("expected Content 'Farewell and thank you!', got '%s'", found.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_UpdateByAccountAndID(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
otherAccount := &model.Account{Name: "Other Update Account"}
|
||||
if err := provider.DB().Create(otherAccount).Error; err != nil {
|
||||
t.Fatalf("failed to create other account: %v", err)
|
||||
}
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
cr := &CannedResponse{AccountID: accountID, ShortCode: "account_update", Content: "Original"}
|
||||
if err := svc.Create(context.Background(), cr); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.UpdateByAccountAndID(context.Background(), otherAccount.ID, cr.ID, map[string]interface{}{"content": "Wrong"}); err == nil {
|
||||
t.Fatal("expected cross-account update to fail")
|
||||
}
|
||||
|
||||
if err := svc.UpdateByAccountAndID(context.Background(), accountID, cr.ID, map[string]interface{}{"content": "Updated"}); err != nil {
|
||||
t.Fatalf("UpdateByAccountAndID failed: %v", err)
|
||||
}
|
||||
found, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByAccountAndID after update failed: %v", err)
|
||||
}
|
||||
if found.Content != "Updated" {
|
||||
t.Fatalf("expected updated content, got %q", found.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_Delete(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
cr := &CannedResponse{
|
||||
AccountID: accountID,
|
||||
ShortCode: "hello",
|
||||
Content: "Hello!",
|
||||
}
|
||||
if err := svc.Create(context.Background(), cr); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Delete(context.Background(), cr.ID); err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
// After soft delete, GetByID should return error.
|
||||
_, err := svc.GetByID(context.Background(), cr.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after Delete, got nil (soft-deleted record should not be found)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_DeleteByAccountAndID(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
otherAccount := &model.Account{Name: "Other Delete Account"}
|
||||
if err := provider.DB().Create(otherAccount).Error; err != nil {
|
||||
t.Fatalf("failed to create other account: %v", err)
|
||||
}
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
cr := &CannedResponse{AccountID: accountID, ShortCode: "account_delete", Content: "Delete"}
|
||||
if err := svc.Create(context.Background(), cr); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.DeleteByAccountAndID(context.Background(), otherAccount.ID, cr.ID); err == nil {
|
||||
t.Fatal("expected cross-account delete to fail")
|
||||
}
|
||||
if _, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID); err != nil {
|
||||
t.Fatalf("record should still exist after failed cross-account delete: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.DeleteByAccountAndID(context.Background(), accountID, cr.ID); err != nil {
|
||||
t.Fatalf("DeleteByAccountAndID failed: %v", err)
|
||||
}
|
||||
if _, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID); err == nil {
|
||||
t.Fatal("expected account-scoped lookup to fail after delete")
|
||||
}
|
||||
|
||||
recreated := &CannedResponse{AccountID: accountID, ShortCode: "account_delete", Content: "Recreated"}
|
||||
if err := svc.Create(context.Background(), recreated); err != nil {
|
||||
t.Fatalf("expected hard-deleted short_code to be reusable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_ListByAccount(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
// Seed multiple canned responses.
|
||||
items := []CannedResponse{
|
||||
{AccountID: accountID, ShortCode: "alpha", Content: "Alpha content"},
|
||||
{AccountID: accountID, ShortCode: "beta", Content: "Beta content"},
|
||||
{AccountID: accountID, ShortCode: "gamma", Content: "Gamma content"},
|
||||
}
|
||||
for i := range items {
|
||||
if err := svc.Create(context.Background(), &items[i]); err != nil {
|
||||
t.Fatalf("Create #%d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a second account with its own canned response — should not appear.
|
||||
db := provider.DB()
|
||||
account2 := &model.Account{Name: "Other Account"}
|
||||
if err := db.Create(account2).Error; err != nil {
|
||||
t.Fatalf("failed to create second account: %v", err)
|
||||
}
|
||||
crOther := &CannedResponse{
|
||||
AccountID: account2.ID,
|
||||
ShortCode: "other",
|
||||
Content: "Other account content",
|
||||
}
|
||||
if err := svc.Create(context.Background(), crOther); err != nil {
|
||||
t.Fatalf("Create other-account canned response failed: %v", err)
|
||||
}
|
||||
|
||||
results, err := svc.ListByAccount(context.Background(), accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByAccount failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("expected 3 results, got %d", len(results))
|
||||
}
|
||||
|
||||
// Results should be ordered by short_code ASC.
|
||||
if results[0].ShortCode != "alpha" {
|
||||
t.Fatalf("expected first result ShortCode 'alpha', got '%s'", results[0].ShortCode)
|
||||
}
|
||||
if results[1].ShortCode != "beta" {
|
||||
t.Fatalf("expected second result ShortCode 'beta', got '%s'", results[1].ShortCode)
|
||||
}
|
||||
if results[2].ShortCode != "gamma" {
|
||||
t.Fatalf("expected third result ShortCode 'gamma', got '%s'", results[2].ShortCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_Search(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
items := []CannedResponse{
|
||||
{AccountID: accountID, ShortCode: "greet_hi", Content: "Hi there! Welcome."},
|
||||
{AccountID: accountID, ShortCode: "greet_hello", Content: "Hello! How can we help?"},
|
||||
{AccountID: accountID, ShortCode: "farewell", Content: "Goodbye and have a great day!"},
|
||||
}
|
||||
for i := range items {
|
||||
if err := svc.Create(context.Background(), &items[i]); err != nil {
|
||||
t.Fatalf("Create #%d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Search for "greet" — should match short_code on first two items.
|
||||
results, err := svc.Search(context.Background(), accountID, "greet")
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
// short_code matches should rank before content-only matches.
|
||||
// Both "greet_hi" and "greet_hello" match on short_code.
|
||||
if len(results) < 2 {
|
||||
t.Fatalf("expected at least 2 results for 'greet', got %d", len(results))
|
||||
}
|
||||
|
||||
// Verify that short_code match results come first.
|
||||
for i := 0; i < 2; i++ {
|
||||
if results[i].ShortCode != "greet_hi" && results[i].ShortCode != "greet_hello" {
|
||||
t.Fatalf("expected short_code match in top results, got '%s'", results[i].ShortCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Search for "great" — matches content in "farewell".
|
||||
results2, err := svc.Search(context.Background(), accountID, "great")
|
||||
if err != nil {
|
||||
t.Fatalf("Search for 'great' failed: %v", err)
|
||||
}
|
||||
if len(results2) != 1 {
|
||||
t.Fatalf("expected 1 result for 'great', got %d", len(results2))
|
||||
}
|
||||
if results2[0].ShortCode != "farewell" {
|
||||
t.Fatalf("expected ShortCode 'farewell', got '%s'", results2[0].ShortCode)
|
||||
}
|
||||
|
||||
// Empty query should return all (same as ListByAccount).
|
||||
results3, err := svc.Search(context.Background(), accountID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Search with empty query failed: %v", err)
|
||||
}
|
||||
if len(results3) != 3 {
|
||||
t.Fatalf("expected 3 results for empty query, got %d", len(results3))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_Search_RanksLikeChatwoot(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
items := []CannedResponse{
|
||||
{AccountID: accountID, ShortCode: "body_only", Content: "Please mention help in the body"},
|
||||
{AccountID: accountID, ShortCode: "say_help", Content: "Generic"},
|
||||
{AccountID: accountID, ShortCode: "help_start", Content: "Generic"},
|
||||
}
|
||||
for i := range items {
|
||||
if err := svc.Create(context.Background(), &items[i]); err != nil {
|
||||
t.Fatalf("Create #%d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
results, err := svc.Search(context.Background(), accountID, "help")
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("expected 3 results, got %d", len(results))
|
||||
}
|
||||
if results[0].ShortCode != "help_start" || results[1].ShortCode != "say_help" || results[2].ShortCode != "body_only" {
|
||||
t.Fatalf("unexpected ranking: %s, %s, %s", results[0].ShortCode, results[1].ShortCode, results[2].ShortCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannedResponseService_GetByShortCode(t *testing.T) {
|
||||
provider := setupCannedTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(provider.DB(), t)
|
||||
svc := NewCannedResponseService(provider)
|
||||
|
||||
cr := &CannedResponse{
|
||||
AccountID: accountID,
|
||||
ShortCode: "welcome",
|
||||
Content: "Welcome to our service!",
|
||||
}
|
||||
if err := svc.Create(context.Background(), cr); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
found, err := svc.GetByShortCode(context.Background(), accountID, "welcome")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByShortCode failed: %v", err)
|
||||
}
|
||||
if found.ID != cr.ID {
|
||||
t.Fatalf("expected ID %d, got %d", cr.ID, found.ID)
|
||||
}
|
||||
if found.ShortCode != "welcome" {
|
||||
t.Fatalf("expected ShortCode 'welcome', got '%s'", found.ShortCode)
|
||||
}
|
||||
|
||||
// Non-existent short_code should return error.
|
||||
_, err = svc.GetByShortCode(context.Background(), accountID, "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent short_code, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user