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,484 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user