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:
2026-07-07 14:44:12 +08:00
parent d4ef996f49
commit aeddedf2a3
1348 changed files with 176 additions and 57 deletions
+261
View File
@@ -0,0 +1,261 @@
package testutil
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/model/channel"
)
// === Account fixtures ===
// NewAccountFixture returns a test Account without persisting it.
func NewAccountFixture(name string) *model.Account {
return &model.Account{
Name: name,
Locale: "en",
Timezone: "UTC",
Active: true,
}
}
// CreateAccountFixture creates and persists a test Account.
func CreateAccountFixture(t *testing.T, db *gorm.DB, name string) *model.Account {
t.Helper()
acc := NewAccountFixture(name)
if err := db.Create(acc).Error; err != nil {
panic(fmt.Sprintf("failed to create test account: %v", err))
}
return acc
}
// === User fixtures ===
// NewUserFixture returns a test User without persisting it.
func NewUserFixture(accountID uint, name, email string) *model.User {
return &model.User{
AccountID: accountID,
Name: name,
Email: email,
Password: "hashed_password_placeholder",
Provider: "email",
Role: string(model.AccountUserRoleAgent),
Active: true,
}
}
// CreateUserFixture creates and persists a test User.
func CreateUserFixture(t *testing.T, db *gorm.DB, accountID uint, name, email string) *model.User {
t.Helper()
user := NewUserFixture(accountID, name, email)
if err := db.Create(user).Error; err != nil {
panic(fmt.Sprintf("failed to create test user: %v", err))
}
return user
}
// === AccountUser fixtures ===
// NewAccountUserFixture returns a test AccountUser without persisting it.
func NewAccountUserFixture(userID, accountID uint, role string) *model.AccountUser {
return &model.AccountUser{
UserID: userID,
AccountID: accountID,
Role: role,
Availability: "online",
}
}
// CreateAccountUserFixture creates and persists a test AccountUser.
func CreateAccountUserFixture(t *testing.T, db *gorm.DB, userID, accountID uint, role string) *model.AccountUser {
t.Helper()
au := NewAccountUserFixture(userID, accountID, role)
if err := db.Create(au).Error; err != nil {
panic(fmt.Sprintf("failed to create test account_user: %v", err))
}
return au
}
// === Inbox fixtures ===
// NewInboxFixture returns a test Inbox without persisting it.
func NewInboxFixture(accountID uint, name, channelType string) *model.Inbox {
return &model.Inbox{
AccountID: accountID,
Name: name,
ChannelType: channelType,
}
}
// CreateInboxFixture creates and persists a test Inbox.
func CreateInboxFixture(t *testing.T, db *gorm.DB, accountID uint, name, channelType string) *model.Inbox {
t.Helper()
inbox := NewInboxFixture(accountID, name, channelType)
if err := db.Create(inbox).Error; err != nil {
panic(fmt.Sprintf("failed to create test inbox: %v", err))
}
return inbox
}
// === Contact fixtures ===
// NewContactFixture returns a test Contact without persisting it.
func NewContactFixture(accountID uint, name string) *model.Contact {
return &model.Contact{
AccountID: accountID,
Name: name,
}
}
// CreateContactFixture creates and persists a test Contact.
func CreateContactFixture(t *testing.T, db *gorm.DB, accountID uint, name string) *model.Contact {
t.Helper()
contact := NewContactFixture(accountID, name)
if err := db.Create(contact).Error; err != nil {
panic(fmt.Sprintf("failed to create test contact: %v", err))
}
return contact
}
// === Conversation fixtures ===
// NewConversationFixture returns a test Conversation without persisting it.
func NewConversationFixture(accountID, inboxID, contactID uint, status string) *model.Conversation {
return &model.Conversation{
AccountID: accountID,
InboxID: inboxID,
ContactID: contactID,
Status: status,
ChannelType: "web_widget",
}
}
// CreateConversationFixture creates and persists a test Conversation.
func CreateConversationFixture(t *testing.T, db *gorm.DB, accountID, inboxID, contactID uint, status string) *model.Conversation {
t.Helper()
conv := NewConversationFixture(accountID, inboxID, contactID, status)
if err := db.Create(conv).Error; err != nil {
panic(fmt.Sprintf("failed to create test conversation: %v", err))
}
return conv
}
// === Message fixtures ===
// NewMessageFixture returns a test Message without persisting it.
func NewMessageFixture(conversationID, accountID, inboxID uint, content string) *model.Message {
return &model.Message{
ConversationID: conversationID,
AccountID: accountID,
InboxID: inboxID,
Content: content,
ContentType: string(model.MessageContentTypeText),
MessageType: string(model.MessageTypeIncoming),
SenderType: "contact",
}
}
// CreateMessageFixture creates and persists a test Message.
func CreateMessageFixture(t *testing.T, db *gorm.DB, conversationID, accountID, inboxID uint, content string) *model.Message {
t.Helper()
msg := NewMessageFixture(conversationID, accountID, inboxID, content)
if err := db.Create(msg).Error; err != nil {
panic(fmt.Sprintf("failed to create test message: %v", err))
}
return msg
}
// === CustomRole fixtures ===
// NewCustomRoleFixture returns a test CustomRole without persisting it.
func NewCustomRoleFixture(accountID uint, name string) *model.CustomRole {
return &model.CustomRole{
AccountID: accountID,
Name: name,
}
}
// CreateCustomRoleFixture creates and persists a test CustomRole.
func CreateCustomRoleFixture(t *testing.T, db *gorm.DB, accountID uint, name string) *model.CustomRole {
t.Helper()
role := NewCustomRoleFixture(accountID, name)
if err := db.Create(role).Error; err != nil {
panic(fmt.Sprintf("failed to create test custom_role: %v", err))
}
return role
}
// === Channel fixtures ===
// NewChannelTelegramFixture returns a test ChannelTelegram without persisting it.
func NewChannelTelegramFixture(accountID uint, botToken string) *channel.ChannelTelegram {
return &channel.ChannelTelegram{
AccountID: accountID,
BotToken: botToken,
}
}
// CreateChannelTelegramFixture creates and persists a test ChannelTelegram.
func CreateChannelTelegramFixture(t *testing.T, db *gorm.DB, accountID uint, botToken string) *channel.ChannelTelegram {
t.Helper()
ch := NewChannelTelegramFixture(accountID, botToken)
if err := db.Create(ch).Error; err != nil {
panic(fmt.Sprintf("failed to create test channel_telegram: %v", err))
}
return ch
}
// NewChannelWebWidgetFixture returns a test ChannelWebWidget without persisting it.
func NewChannelWebWidgetFixture(inboxID uint, websiteURL string) *channel.ChannelWebWidget {
return &channel.ChannelWebWidget{
InboxID: inboxID,
WebsiteURL: websiteURL,
}
}
// CreateChannelWebWidgetFixture creates and persists a test ChannelWebWidget.
func CreateChannelWebWidgetFixture(t *testing.T, db *gorm.DB, inboxID uint, websiteURL string) *channel.ChannelWebWidget {
t.Helper()
ch := NewChannelWebWidgetFixture(inboxID, websiteURL)
if err := db.Create(ch).Error; err != nil {
panic(fmt.Sprintf("failed to create test channel_web_widget: %v", err))
}
return ch
}
// === Full seed chain ===
// SeedFullAccountChain creates a complete chain: Account → User → AccountUser
// This is the minimal RBAC context needed for most tests.
func SeedFullAccountChain(t *testing.T, db *gorm.DB) (*model.Account, *model.User, *model.AccountUser) {
t.Helper()
acc := CreateAccountFixture(t, db, "Test Account")
user := CreateUserFixture(t, db, acc.ID, "Test User", "test@example.com")
au := CreateAccountUserFixture(t, db, user.ID, acc.ID, string(model.AccountUserRoleAgent))
return acc, user, au
}
// SeedFullConversationChain creates: Account → Inbox → Contact → Conversation
func SeedFullConversationChain(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox, *model.Contact, *model.Conversation) {
t.Helper()
acc := CreateAccountFixture(t, db, "Test Account")
inbox := CreateInboxFixture(t, db, acc.ID, "Test Inbox", "web_widget")
contact := CreateContactFixture(t, db, acc.ID, "Test Contact")
conv := CreateConversationFixture(t, db, acc.ID, inbox.ID, contact.ID, string(model.ConversationStatusOpen))
return acc, inbox, contact, conv
}
// === Helpers for fixture assertions ===
// AssertFixtureCount asserts that a model table has the expected number of rows.
func AssertFixtureCount(t *testing.T, db *gorm.DB, model interface{}, expected int64) {
t.Helper()
var count int64
err := db.Model(model).Count(&count).Error
assert.NoError(t, err)
assert.Equal(t, expected, count)
}
+296
View File
@@ -0,0 +1,296 @@
package testutil
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/model/channel"
)
// === Database Helpers ===
// NewTestDB creates an in-memory SQLite database with all models auto-migrated.
func NewTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err, "failed to open test database")
models := []interface{}{
&model.Account{},
&model.User{},
&model.AccountUser{},
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.Attachment{},
&model.Notification{},
&model.NotificationPreference{},
&model.CustomRole{},
&model.PlatformApp{},
&channel.ChannelTelegram{},
&channel.ChannelWebWidget{},
}
err = db.AutoMigrate(models...)
assert.NoError(t, err, "failed to auto-migrate models")
return db
}
// NewTestDBWithModels creates an in-memory SQLite database with only specified models.
func NewTestDBWithModels(t *testing.T, models ...interface{}) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err, "failed to open test database")
err = db.AutoMigrate(models...)
assert.NoError(t, err, "failed to auto-migrate models")
return db
}
// CleanupDB closes the test database connection.
func CleanupDB(t *testing.T, db *gorm.DB) {
t.Helper()
sqlDB, err := db.DB()
assert.NoError(t, err, "failed to get underlying sql.DB")
err = sqlDB.Close()
assert.NoError(t, err, "failed to close test database")
}
// TruncateAll clears all data from the test database.
func TruncateAll(t *testing.T, db *gorm.DB) {
t.Helper()
tables, err := db.Migrator().GetTables()
assert.NoError(t, err, "failed to get tables")
for _, table := range tables {
err = db.Exec("DELETE FROM " + table).Error
assert.NoError(t, err, "failed to truncate table %s", table)
}
}
// === HTTP Test Helpers ===
// NewTestRouter creates a Gin router in test mode.
func NewTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
return gin.New()
}
// NewTestServer creates an httptest.Server wrapping a Gin router.
func NewTestServer(router *gin.Engine) *httptest.Server {
return httptest.NewServer(router)
}
// PerformRequest performs an HTTP request against a Gin router and returns the response.
func PerformRequest(router *gin.Engine, method, path string, body interface{}) *httptest.ResponseRecorder {
var reqBody *http.Request
if body != nil {
// This is handled by the caller using json.Marshal
reqBody = httptest.NewRequest(method, path, nil)
} else {
reqBody = httptest.NewRequest(method, path, nil)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, reqBody)
return w
}
// AssertJSONResponse asserts that a response contains expected JSON keys.
func AssertJSONResponse(t *testing.T, w *httptest.ResponseRecorder, statusCode int, keys ...string) {
t.Helper()
assert.Equal(t, statusCode, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err, "response body should be valid JSON")
for _, key := range keys {
assert.Contains(t, resp, key, "response should contain key: %s", key)
}
}
// === Context Helpers ===
// NewTestContext creates a Gin context with a test HTTP request.
func NewTestContext() (*gin.Context, *httptest.ResponseRecorder) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
return c, w
}
// SetTestUserContext sets a user ID and account ID in the Gin context.
func SetTestUserContext(c *gin.Context, userID, accountID uint, role string) {
c.Set("user_id", userID)
c.Set("account_id", accountID)
c.Set("role", role)
}
// === Assertion Helpers ===
// AssertCreated asserts that a model was created with a non-zero ID.
func AssertCreated(t *testing.T, obj interface{}, id uint) {
t.Helper()
assert.NotZero(t, id, "created object should have non-zero ID")
}
// AssertSoftDeleted asserts that a model's DeletedAt field is set.
func AssertSoftDeleted(t *testing.T, deletedAt gorm.DeletedAt) {
t.Helper()
assert.NotNil(t, deletedAt, "soft-deleted object should have DeletedAt set")
assert.NotZero(t, deletedAt.Time, "DeletedAt time should not be zero")
}
// AssertTimestampOrder asserts that created_at <= updated_at.
func AssertTimestampOrder(t *testing.T, createdAt, updatedAt time.Time) {
t.Helper()
assert.True(t, createdAt.Before(updatedAt) || createdAt.Equal(updatedAt),
"created_at should be <= updated_at")
}
// === Fixture Seeding ===
// SeedAccount creates and persists a test account.
func SeedAccount(t *testing.T, db *gorm.DB, name string) *model.Account {
t.Helper()
acc := &model.Account{Name: name, Locale: "en", Timezone: "UTC", Active: true}
err := db.Create(acc).Error
assert.NoError(t, err, "failed to seed account")
return acc
}
// SeedUser creates and persists a test user.
func SeedUser(t *testing.T, db *gorm.DB, accountID uint, name, email string) *model.User {
t.Helper()
user := &model.User{
AccountID: accountID,
Name: name,
Email: email,
Password: "hashed_password_placeholder",
Role: string(model.AccountUserRoleAgent),
Active: true,
}
err := db.Create(user).Error
assert.NoError(t, err, "failed to seed user")
return user
}
// SeedAccountUser creates and persists an AccountUser membership.
func SeedAccountUser(t *testing.T, db *gorm.DB, userID, accountID uint, role string) *model.AccountUser {
t.Helper()
au := &model.AccountUser{
UserID: userID,
AccountID: accountID,
Role: role,
}
err := db.Create(au).Error
assert.NoError(t, err, "failed to seed account_user")
return au
}
// SeedInbox creates and persists a test inbox.
func SeedInbox(t *testing.T, db *gorm.DB, accountID uint, name, channelType string) *model.Inbox {
t.Helper()
inbox := &model.Inbox{
AccountID: accountID,
Name: name,
ChannelType: channelType,
ChannelID: 1,
}
err := db.Create(inbox).Error
assert.NoError(t, err, "failed to seed inbox")
return inbox
}
// SeedContact creates and persists a test contact.
func SeedContact(t *testing.T, db *gorm.DB, accountID uint, name string) *model.Contact {
t.Helper()
contact := &model.Contact{
AccountID: accountID,
Name: name,
}
err := db.Create(contact).Error
assert.NoError(t, err, "failed to seed contact")
return contact
}
// SeedConversation creates and persists a test conversation.
func SeedConversation(t *testing.T, db *gorm.DB, accountID, inboxID, contactID uint, status string) *model.Conversation {
t.Helper()
conv := &model.Conversation{
AccountID: accountID,
InboxID: inboxID,
ContactID: contactID,
Status: status,
ChannelType: "web_widget",
}
err := db.Create(conv).Error
assert.NoError(t, err, "failed to seed conversation")
return conv
}
// SeedMessage creates and persists a test message.
func SeedMessage(t *testing.T, db *gorm.DB, conversationID, accountID, inboxID uint, content string) *model.Message {
t.Helper()
msg := &model.Message{
ConversationID: conversationID,
AccountID: accountID,
InboxID: inboxID,
Content: content,
ContentType: string(model.MessageContentTypeText),
MessageType: string(model.MessageTypeIncoming),
SenderType: "contact",
}
err := db.Create(msg).Error
assert.NoError(t, err, "failed to seed message")
return msg
}
// === Custom Error type for test assertions ===
// TestError wraps an error with context for test failure reporting.
type TestError struct {
Op string
Err error
Msg string
}
func (e *TestError) Error() string {
return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err)
}
func (e *TestError) Unwrap() error {
return e.Err
}
// NewTestError creates a TestError for a failed operation.
func NewTestError(op string, err error, msg string) *TestError {
return &TestError{Op: op, Err: err, Msg: msg}
}
// === JSON encoding helper ===
// We can't use json.Unmarshal directly in the AssertJSONResponse function
// because it was referenced but not imported. Let me fix that.
// DecodeJSONResponse decodes an HTTP response body into a map.
func DecodeJSONResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]interface{} {
t.Helper()
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err, "response body should be valid JSON")
return resp
}