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.
485 lines
15 KiB
Go
485 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
)
|
|
|
|
// ========== Test Setup ==========
|
|
|
|
func setupCustomAttrValueServiceTestDB(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.CustomAttributeDefinition{},
|
|
&model.Contact{},
|
|
&model.ContactInbox{},
|
|
&model.Inbox{},
|
|
&model.Conversation{},
|
|
&model.Message{},
|
|
); err != nil {
|
|
t.Fatalf("failed to auto-migrate models: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.Close()
|
|
})
|
|
return db
|
|
}
|
|
|
|
func setupCustomAttrValueService(t *testing.T) (*CustomAttributeValueService, uint, *gorm.DB) {
|
|
t.Helper()
|
|
db := setupCustomAttrValueServiceTestDB(t)
|
|
|
|
defRepo := repository.NewCustomAttributeDefinitionRepo(db)
|
|
convRepo := repository.NewConversationRepo(db)
|
|
contactRepo := repository.NewContactRepo(db)
|
|
|
|
svc := NewCustomAttributeValueService(defRepo, convRepo, contactRepo)
|
|
|
|
account := createTestAccount(t, db)
|
|
return svc, account.ID, db
|
|
}
|
|
|
|
// Helper: create a conversation attribute definition
|
|
func createConvAttrDef(t *testing.T, svc *CustomAttributeValueService, accountID uint, attrType string) {
|
|
t.Helper()
|
|
defRepo := svc.defRepo
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: "conv_" + attrType,
|
|
AttributeDisplayName: "Conv " + attrType,
|
|
AttributeType: attrType,
|
|
AttributeModel: "conversation",
|
|
}
|
|
require.NoError(t, defRepo.Create(context.Background(), def))
|
|
}
|
|
|
|
// Helper: create a contact attribute definition
|
|
func createContactAttrDef(t *testing.T, svc *CustomAttributeValueService, accountID uint, attrType string) {
|
|
t.Helper()
|
|
defRepo := svc.defRepo
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: "contact_" + attrType,
|
|
AttributeDisplayName: "Contact " + attrType,
|
|
AttributeType: attrType,
|
|
AttributeModel: "contact",
|
|
}
|
|
require.NoError(t, defRepo.Create(context.Background(), def))
|
|
}
|
|
|
|
// Helper: create a minimal conversation (local version to avoid clash with service_test_helper.go)
|
|
func createTestConversationForAttr(t *testing.T, db *gorm.DB, accountID uint) *model.Conversation {
|
|
t.Helper()
|
|
inbox := &model.Inbox{AccountID: accountID, Name: "Test Inbox", ChannelType: "web_widget"}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
contact := &model.Contact{AccountID: accountID, Name: "Test Contact"}
|
|
require.NoError(t, db.Create(contact).Error)
|
|
conv := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open"}
|
|
require.NoError(t, db.Create(conv).Error)
|
|
return conv
|
|
}
|
|
|
|
// Helper: create a minimal contact
|
|
func createTestContactForAttr(t *testing.T, db *gorm.DB, accountID uint) *model.Contact {
|
|
t.Helper()
|
|
contact := &model.Contact{AccountID: accountID, Name: "Attr Test Contact"}
|
|
require.NoError(t, db.Create(contact).Error)
|
|
return contact
|
|
}
|
|
|
|
// ========== validateValueType tests ==========
|
|
|
|
func TestValidateValueType_Text(t *testing.T) {
|
|
assert.NoError(t, validateValueType("text", "hello"))
|
|
assert.Error(t, validateValueType("text", 42))
|
|
assert.Error(t, validateValueType("text", true))
|
|
}
|
|
|
|
func TestValidateValueType_Link(t *testing.T) {
|
|
assert.NoError(t, validateValueType("link", "https://example.com"))
|
|
assert.Error(t, validateValueType("link", 42))
|
|
}
|
|
|
|
func TestValidateValueType_Number(t *testing.T) {
|
|
assert.NoError(t, validateValueType("number", float64(3.14)))
|
|
assert.NoError(t, validateValueType("number", int(42)))
|
|
assert.NoError(t, validateValueType("number", int64(100)))
|
|
assert.NoError(t, validateValueType("number", int32(5)))
|
|
assert.NoError(t, validateValueType("number", float32(1.5)))
|
|
assert.Error(t, validateValueType("number", "not a number"))
|
|
assert.Error(t, validateValueType("number", true))
|
|
}
|
|
|
|
func TestValidateValueType_Date(t *testing.T) {
|
|
assert.NoError(t, validateValueType("date", "2024-01-01"))
|
|
assert.Error(t, validateValueType("date", 42))
|
|
assert.Error(t, validateValueType("date", true))
|
|
}
|
|
|
|
func TestValidateValueType_Checkbox(t *testing.T) {
|
|
assert.NoError(t, validateValueType("checkbox", true))
|
|
assert.NoError(t, validateValueType("checkbox", false))
|
|
assert.Error(t, validateValueType("checkbox", "yes"))
|
|
assert.Error(t, validateValueType("checkbox", 1))
|
|
}
|
|
|
|
func TestValidateValueType_List(t *testing.T) {
|
|
// List accepts any value
|
|
assert.NoError(t, validateValueType("list", "anything"))
|
|
assert.NoError(t, validateValueType("list", 42))
|
|
assert.NoError(t, validateValueType("list", true))
|
|
assert.NoError(t, validateValueType("list", []string{"a", "b"}))
|
|
}
|
|
|
|
func TestValidateValueType_UnknownType(t *testing.T) {
|
|
// Unknown types are accepted
|
|
assert.NoError(t, validateValueType("unknown", "anything"))
|
|
}
|
|
|
|
// ========== mergeIntoJSON tests ==========
|
|
|
|
func TestMergeIntoJSON_NewKey(t *testing.T) {
|
|
result, err := mergeIntoJSON(nil, "priority", "high")
|
|
require.NoError(t, err)
|
|
|
|
var data map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result, &data))
|
|
assert.Equal(t, "high", data["priority"])
|
|
}
|
|
|
|
func TestMergeIntoJSON_ExistingJSON(t *testing.T) {
|
|
existing := datatypes.JSON(`{"status":"open","source":"web"}`)
|
|
result, err := mergeIntoJSON(existing, "priority", "high")
|
|
require.NoError(t, err)
|
|
|
|
var data map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result, &data))
|
|
assert.Equal(t, "high", data["priority"])
|
|
assert.Equal(t, "open", data["status"])
|
|
assert.Equal(t, "web", data["source"])
|
|
}
|
|
|
|
func TestMergeIntoJSON_OverwriteExistingKey(t *testing.T) {
|
|
existing := datatypes.JSON(`{"priority":"low"}`)
|
|
result, err := mergeIntoJSON(existing, "priority", "high")
|
|
require.NoError(t, err)
|
|
|
|
var data map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result, &data))
|
|
assert.Equal(t, "high", data["priority"])
|
|
}
|
|
|
|
func TestMergeIntoJSON_EmptyJSON(t *testing.T) {
|
|
result, err := mergeIntoJSON(datatypes.JSON(``), "key", "val")
|
|
require.NoError(t, err)
|
|
|
|
var data map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result, &data))
|
|
assert.Equal(t, "val", data["key"])
|
|
}
|
|
|
|
// ========== removeFromJSON tests ==========
|
|
|
|
func TestRemoveFromJSON_ExistingKey(t *testing.T) {
|
|
existing := datatypes.JSON(`{"status":"open","priority":"high"}`)
|
|
result, err := removeFromJSON(existing, "priority")
|
|
require.NoError(t, err)
|
|
|
|
var data map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result, &data))
|
|
assert.Equal(t, "open", data["status"])
|
|
assert.NotContains(t, data, "priority")
|
|
}
|
|
|
|
func TestRemoveFromJSON_NonexistentKey(t *testing.T) {
|
|
existing := datatypes.JSON(`{"status":"open"}`)
|
|
result, err := removeFromJSON(existing, "nonexistent")
|
|
require.NoError(t, err)
|
|
|
|
var data map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result, &data))
|
|
assert.Equal(t, "open", data["status"])
|
|
}
|
|
|
|
func TestRemoveFromJSON_NilOrEmpty(t *testing.T) {
|
|
result, err := removeFromJSON(nil, "key")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, datatypes.JSON("{}"), result)
|
|
|
|
result, err = removeFromJSON(datatypes.JSON(``), "key")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, datatypes.JSON("{}"), result)
|
|
}
|
|
|
|
func TestRemoveFromJSON_AllKeys(t *testing.T) {
|
|
existing := datatypes.JSON(`{"a":1,"b":2}`)
|
|
result, err := removeFromJSON(existing, "a")
|
|
require.NoError(t, err)
|
|
|
|
result, err = removeFromJSON(result, "b")
|
|
require.NoError(t, err)
|
|
|
|
var data map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result, &data))
|
|
assert.Empty(t, data)
|
|
}
|
|
|
|
// ========== SetConversationAttributeValue ==========
|
|
|
|
func TestCustomAttributeValueService_SetConversationAttributeValue(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
// Create a conversation attribute definition (text type)
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: "conv_priority",
|
|
AttributeDisplayName: "Conv Priority",
|
|
AttributeType: "text",
|
|
AttributeModel: "conversation",
|
|
}
|
|
require.NoError(t, svc.defRepo.Create(context.Background(), def))
|
|
|
|
conv := createTestConversationForAttr(t, db, accountID)
|
|
|
|
req := &SetAttributeValueRequest{
|
|
AttributeName: "conv_priority",
|
|
Value: "high",
|
|
}
|
|
|
|
result, err := svc.SetConversationAttributeValue(context.Background(), accountID, conv.ID, req)
|
|
require.NoError(t, err)
|
|
|
|
var attrs map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result.CustomAttributes, &attrs))
|
|
assert.Equal(t, "high", attrs["conv_priority"])
|
|
}
|
|
|
|
func TestCustomAttributeValueService_SetConversationAttributeValue_WrongType(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
// Create a number-type attribute definition
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: "conv_score",
|
|
AttributeDisplayName: "Conv Score",
|
|
AttributeType: "number",
|
|
AttributeModel: "conversation",
|
|
}
|
|
require.NoError(t, svc.defRepo.Create(context.Background(), def))
|
|
|
|
conv := createTestConversationForAttr(t, db, accountID)
|
|
|
|
req := &SetAttributeValueRequest{
|
|
AttributeName: "conv_score",
|
|
Value: "not_a_number", // wrong type
|
|
}
|
|
|
|
_, err := svc.SetConversationAttributeValue(context.Background(), accountID, conv.ID, req)
|
|
assert.Error(t, err, "should fail when value type doesn't match attribute type")
|
|
}
|
|
|
|
func TestCustomAttributeValueService_SetConversationAttributeValue_DefNotFound(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
conv := createTestConversationForAttr(t, db, accountID)
|
|
|
|
req := &SetAttributeValueRequest{
|
|
AttributeName: "nonexistent_attr",
|
|
Value: "val",
|
|
}
|
|
|
|
_, err := svc.SetConversationAttributeValue(context.Background(), accountID, conv.ID, req)
|
|
assert.Error(t, err, "should fail when attribute definition doesn't exist")
|
|
}
|
|
|
|
func TestCustomAttributeValueService_SetConversationAttributeValue_ConvNotFound(t *testing.T) {
|
|
svc, accountID, _ := setupCustomAttrValueService(t)
|
|
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: "conv_priority",
|
|
AttributeDisplayName: "Conv Priority",
|
|
AttributeType: "text",
|
|
AttributeModel: "conversation",
|
|
}
|
|
require.NoError(t, svc.defRepo.Create(context.Background(), def))
|
|
|
|
req := &SetAttributeValueRequest{
|
|
AttributeName: "conv_priority",
|
|
Value: "high",
|
|
}
|
|
|
|
_, err := svc.SetConversationAttributeValue(context.Background(), accountID, 9999, req)
|
|
assert.Error(t, err, "should fail when conversation doesn't exist")
|
|
}
|
|
|
|
// ========== RemoveConversationAttributeValue ==========
|
|
|
|
func TestCustomAttributeValueService_RemoveConversationAttributeValue(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: "conv_priority",
|
|
AttributeDisplayName: "Conv Priority",
|
|
AttributeType: "text",
|
|
AttributeModel: "conversation",
|
|
}
|
|
require.NoError(t, svc.defRepo.Create(context.Background(), def))
|
|
|
|
conv := createTestConversationForAttr(t, db, accountID)
|
|
|
|
// First set the value
|
|
setReq := &SetAttributeValueRequest{
|
|
AttributeName: "conv_priority",
|
|
Value: "high",
|
|
}
|
|
_, err := svc.SetConversationAttributeValue(context.Background(), accountID, conv.ID, setReq)
|
|
require.NoError(t, err)
|
|
|
|
// Then remove it
|
|
result, err := svc.RemoveConversationAttributeValue(context.Background(), accountID, conv.ID, "conv_priority")
|
|
require.NoError(t, err)
|
|
|
|
var attrs map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result.CustomAttributes, &attrs))
|
|
assert.NotContains(t, attrs, "conv_priority")
|
|
}
|
|
|
|
func TestCustomAttributeValueService_RemoveConversationAttributeValue_DefNotFound(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
conv := createTestConversationForAttr(t, db, accountID)
|
|
|
|
_, err := svc.RemoveConversationAttributeValue(context.Background(), accountID, conv.ID, "nonexistent")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// ========== SetContactAttributeValue ==========
|
|
|
|
func TestCustomAttributeValueService_SetContactAttributeValue(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: "contact_level",
|
|
AttributeDisplayName: "Contact Level",
|
|
AttributeType: "text",
|
|
AttributeModel: "contact",
|
|
}
|
|
require.NoError(t, svc.defRepo.Create(context.Background(), def))
|
|
|
|
contact := createTestContactForAttr(t, db, accountID)
|
|
|
|
req := &SetAttributeValueRequest{
|
|
AttributeName: "contact_level",
|
|
Value: "VIP",
|
|
}
|
|
|
|
result, err := svc.SetContactAttributeValue(context.Background(), accountID, contact.ID, req)
|
|
require.NoError(t, err)
|
|
|
|
var attrs map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result.CustomAttributes, &attrs))
|
|
assert.Equal(t, "VIP", attrs["contact_level"])
|
|
}
|
|
|
|
func TestCustomAttributeValueService_SetContactAttributeValue_WrongType(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: "contact_score",
|
|
AttributeDisplayName: "Contact Score",
|
|
AttributeType: "number",
|
|
AttributeModel: "contact",
|
|
}
|
|
require.NoError(t, svc.defRepo.Create(context.Background(), def))
|
|
|
|
contact := createTestContactForAttr(t, db, accountID)
|
|
|
|
req := &SetAttributeValueRequest{
|
|
AttributeName: "contact_score",
|
|
Value: "not_a_number",
|
|
}
|
|
|
|
_, err := svc.SetContactAttributeValue(context.Background(), accountID, contact.ID, req)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestCustomAttributeValueService_SetContactAttributeValue_DefNotFound(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
contact := createTestContactForAttr(t, db, accountID)
|
|
|
|
req := &SetAttributeValueRequest{
|
|
AttributeName: "nonexistent",
|
|
Value: "val",
|
|
}
|
|
|
|
_, err := svc.SetContactAttributeValue(context.Background(), accountID, contact.ID, req)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// ========== RemoveContactAttributeValue ==========
|
|
|
|
func TestCustomAttributeValueService_RemoveContactAttributeValue(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: accountID,
|
|
AttributeName: "contact_level",
|
|
AttributeDisplayName: "Contact Level",
|
|
AttributeType: "text",
|
|
AttributeModel: "contact",
|
|
}
|
|
require.NoError(t, svc.defRepo.Create(context.Background(), def))
|
|
|
|
contact := createTestContactForAttr(t, db, accountID)
|
|
|
|
// Set value first
|
|
setReq := &SetAttributeValueRequest{
|
|
AttributeName: "contact_level",
|
|
Value: "VIP",
|
|
}
|
|
_, err := svc.SetContactAttributeValue(context.Background(), accountID, contact.ID, setReq)
|
|
require.NoError(t, err)
|
|
|
|
// Remove it
|
|
result, err := svc.RemoveContactAttributeValue(context.Background(), accountID, contact.ID, "contact_level")
|
|
require.NoError(t, err)
|
|
|
|
var attrs map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(result.CustomAttributes, &attrs))
|
|
assert.NotContains(t, attrs, "contact_level")
|
|
}
|
|
|
|
func TestCustomAttributeValueService_RemoveContactAttributeValue_DefNotFound(t *testing.T) {
|
|
svc, accountID, db := setupCustomAttrValueService(t)
|
|
|
|
contact := createTestContactForAttr(t, db, accountID)
|
|
|
|
_, err := svc.RemoveContactAttributeValue(context.Background(), accountID, contact.ID, "nonexistent")
|
|
assert.Error(t, err)
|
|
}
|