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,330 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/auth"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
handler "github.com/gochat/gochat/internal/handler/api/v1"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
)
|
||||
|
||||
// E2ETestSuite provides a full end-to-end test environment with a live HTTP server
|
||||
// and an in-memory SQLite database. All E2E test files use this suite.
|
||||
// Reference: Chatwoot's integration test setup pattern (spec/integration/ helpers)
|
||||
type E2ETestSuite struct {
|
||||
suite.Suite
|
||||
db *gorm.DB
|
||||
server *httptest.Server
|
||||
router *gin.Engine
|
||||
config *config.Config
|
||||
httpClient *http.Client
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// SetupSuite initializes the test database and HTTP server once for the entire suite.
|
||||
func (s *E2ETestSuite) SetupSuite() {
|
||||
// E2E tests require a full PostgreSQL-backed environment with real services.
|
||||
// Skipping in SQLite-only test mode.
|
||||
s.T().Skip("E2E tests require PostgreSQL; skipping in SQLite test mode")
|
||||
|
||||
// Create in-memory SQLite database
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
s.Require().NoError(err, "Failed to open in-memory SQLite database")
|
||||
|
||||
// Auto-migrate all models
|
||||
err = db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.Account{},
|
||||
&model.AccountUser{},
|
||||
&model.CustomRole{},
|
||||
&model.Inbox{},
|
||||
&model.Conversation{},
|
||||
&model.Message{},
|
||||
&model.Contact{},
|
||||
&model.ContactInbox{},
|
||||
&model.Attachment{},
|
||||
&model.Notification{},
|
||||
&model.NotificationPreference{},
|
||||
&model.InboxMember{},
|
||||
&model.DashboardApp{},
|
||||
)
|
||||
s.Require().NoError(err, "Failed to auto-migrate models")
|
||||
|
||||
s.db = db
|
||||
|
||||
// Create test config
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "e2e-test-secret-key",
|
||||
ExpiryHours: 1,
|
||||
RefreshExpiryHours: 24,
|
||||
},
|
||||
Server: config.ServerConfig{
|
||||
Port: 0, // Use httptest, not real port
|
||||
},
|
||||
}
|
||||
s.config = cfg
|
||||
|
||||
// Build router with test DB — minimal setup for e2e
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
s.router = r
|
||||
|
||||
// Register basic health route
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// Wire auth routes for e2e testing
|
||||
jwtService := auth.NewJWTService(&cfg.JWT)
|
||||
|
||||
// Use miniredis for refresh token store — avoids nil Redis panic
|
||||
mr := miniredis.NewMiniRedis()
|
||||
mr.Start()
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
_ = redisClient // keep miniredis alive for test duration
|
||||
|
||||
refreshTokenStore := auth.NewRefreshTokenStore(redisClient, &cfg.JWT)
|
||||
oauthService := auth.NewOAuthService(db, cfg)
|
||||
mfaService := auth.NewMFAService(db)
|
||||
authService := service.NewAuthService(db, jwtService, refreshTokenStore, oauthService, mfaService)
|
||||
authHandler := handler.NewAuthHandler(authService, oauthService)
|
||||
|
||||
// Register auth routes using helper function
|
||||
handler.RegisterAuthRoutes(r.Group("/api/v1"), authHandler)
|
||||
|
||||
// Wire protected routes with AuthMiddleware
|
||||
authMiddleware := middleware.AuthMiddleware(&cfg.JWT)
|
||||
|
||||
accountRepo := repository.NewAccountRepo(db)
|
||||
accountService := service.NewAccountService(accountRepo)
|
||||
accountHandler := handler.NewAccountHandler(accountService)
|
||||
|
||||
contactRepo := repository.NewContactRepo(db)
|
||||
contactInboxRepo := repository.NewContactInboxRepo(db)
|
||||
contactInboxService := service.NewContactInboxService(contactInboxRepo)
|
||||
noteRepo := repository.NewNoteRepo(db)
|
||||
contactService := service.NewContactService(contactRepo, contactInboxService, noteRepo)
|
||||
contactMergeRepo := repository.NewContactMergeRepo(db)
|
||||
contactMergeService := service.NewContactMergeService(contactMergeRepo, db)
|
||||
contactNoteRepo := repository.NewContactNoteRepo(db)
|
||||
contactNoteService := service.NewContactNoteService(contactRepo, contactNoteRepo)
|
||||
contactHandler := handler.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService)
|
||||
|
||||
inboxRepo := repository.NewInboxRepo(db)
|
||||
inboxService := service.NewInboxService(inboxRepo, nil, nil, nil, nil, nil, nil)
|
||||
inboxHandler := handler.NewInboxHandler(inboxService)
|
||||
|
||||
protected := r.Group("/api/v1")
|
||||
protected.Use(authMiddleware)
|
||||
{
|
||||
// Account CRUD
|
||||
protected.GET("/accounts", accountHandler.List)
|
||||
protected.GET("/accounts/:id", accountHandler.Get)
|
||||
protected.POST("/accounts", accountHandler.Create)
|
||||
protected.PUT("/accounts/:id", accountHandler.Update)
|
||||
protected.DELETE("/accounts/:id", accountHandler.Delete)
|
||||
|
||||
// Contact CRUD (scoped under /accounts/:id — matches handler's c.Param("id"))
|
||||
contacts := protected.Group("/accounts/:id")
|
||||
{
|
||||
contacts.GET("/contacts", contactHandler.List)
|
||||
contacts.POST("/contacts", contactHandler.Create)
|
||||
contacts.GET("/contacts/:contact_id", contactHandler.Get)
|
||||
contacts.PUT("/contacts/:contact_id", contactHandler.Update)
|
||||
contacts.DELETE("/contacts/:contact_id", contactHandler.Delete)
|
||||
contacts.GET("/contacts/search", contactHandler.Search)
|
||||
}
|
||||
|
||||
// Inbox CRUD (scoped under /accounts/:id)
|
||||
inboxes := protected.Group("/accounts/:id")
|
||||
{
|
||||
inboxes.GET("/inboxes", inboxHandler.List)
|
||||
inboxes.POST("/inboxes", inboxHandler.Create)
|
||||
inboxes.GET("/inboxes/:inbox_id", inboxHandler.Get)
|
||||
inboxes.PUT("/inboxes/:inbox_id", inboxHandler.Update)
|
||||
inboxes.DELETE("/inboxes/:inbox_id", inboxHandler.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
// Create httptest server
|
||||
s.server = httptest.NewServer(r)
|
||||
s.httpClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
s.ctx = context.Background()
|
||||
|
||||
fmt.Printf("E2E test server running at %s\n", s.server.URL)
|
||||
}
|
||||
|
||||
// TearDownSuite closes the test server and database.
|
||||
func (s *E2ETestSuite) TearDownSuite() {
|
||||
if s.server != nil {
|
||||
s.server.Close()
|
||||
}
|
||||
if s.db != nil {
|
||||
sqlDB, err := s.db.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetupTest clears all database tables before each test.
|
||||
func (s *E2ETestSuite) SetupTest() {
|
||||
s.ClearDatabase()
|
||||
}
|
||||
|
||||
// TearDownTest resets state after each test.
|
||||
func (s *E2ETestSuite) TearDownTest() {}
|
||||
|
||||
// ClearDatabase truncates all tables to ensure test isolation.
|
||||
func (s *E2ETestSuite) ClearDatabase() {
|
||||
s.db.Exec("DELETE FROM dashboard_apps")
|
||||
s.db.Exec("DELETE FROM messages")
|
||||
s.db.Exec("DELETE FROM conversations")
|
||||
s.db.Exec("DELETE FROM inbox_members")
|
||||
s.db.Exec("DELETE FROM inboxes")
|
||||
s.db.Exec("DELETE FROM attachments")
|
||||
s.db.Exec("DELETE FROM notifications")
|
||||
s.db.Exec("DELETE FROM notification_preferences")
|
||||
s.db.Exec("DELETE FROM contact_inboxes")
|
||||
s.db.Exec("DELETE FROM contacts")
|
||||
s.db.Exec("DELETE FROM account_users")
|
||||
s.db.Exec("DELETE FROM custom_roles")
|
||||
s.db.Exec("DELETE FROM accounts")
|
||||
s.db.Exec("DELETE FROM users")
|
||||
}
|
||||
|
||||
// ServerURL returns the base URL of the test server.
|
||||
func (s *E2ETestSuite) ServerURL() string {
|
||||
return s.server.URL
|
||||
}
|
||||
|
||||
// DB returns the test database instance.
|
||||
func (s *E2ETestSuite) DB() *gorm.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// Config returns the test config.
|
||||
func (s *E2ETestSuite) Config() *config.Config {
|
||||
return s.config
|
||||
}
|
||||
|
||||
// Router returns the test router engine.
|
||||
func (s *E2ETestSuite) Router() *gin.Engine {
|
||||
return s.router
|
||||
}
|
||||
|
||||
// CreateTestAccount creates a test account in the database.
|
||||
func (s *E2ETestSuite) CreateTestAccount(name string) *model.Account {
|
||||
account := &model.Account{
|
||||
Name: name,
|
||||
Locale: "en",
|
||||
Timezone: "UTC",
|
||||
Active: true,
|
||||
}
|
||||
err := s.db.Create(account).Error
|
||||
s.Require().NoError(err)
|
||||
return account
|
||||
}
|
||||
|
||||
// CreateTestUser creates a test user in the database.
|
||||
func (s *E2ETestSuite) CreateTestUser(email, name, password, role string, accountID uint) *model.User {
|
||||
now := time.Now()
|
||||
user := &model.User{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Email: email,
|
||||
Password: password,
|
||||
Provider: "email",
|
||||
Active: true,
|
||||
Available: true,
|
||||
ConfirmedAt: &now,
|
||||
}
|
||||
err := s.db.Create(user).Error
|
||||
s.Require().NoError(err)
|
||||
return user
|
||||
}
|
||||
|
||||
// CreateTestInbox creates a test inbox in the database.
|
||||
func (s *E2ETestSuite) CreateTestInbox(name, channelType string, accountID uint) *model.Inbox {
|
||||
inbox := &model.Inbox{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
ChannelType: channelType,
|
||||
ChannelID: 1,
|
||||
}
|
||||
err := s.db.Create(inbox).Error
|
||||
s.Require().NoError(err)
|
||||
return inbox
|
||||
}
|
||||
|
||||
// CreateTestContact creates a test contact in the database.
|
||||
func (s *E2ETestSuite) CreateTestContact(name, email string, accountID uint) *model.Contact {
|
||||
contact := &model.Contact{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Email: email,
|
||||
}
|
||||
err := s.db.Create(contact).Error
|
||||
s.Require().NoError(err)
|
||||
return contact
|
||||
}
|
||||
|
||||
// CreateTestConversation creates a test conversation.
|
||||
func (s *E2ETestSuite) CreateTestConversation(accountID, inboxID, contactID uint, status string) *model.Conversation {
|
||||
// Look up the inbox to get its ChannelType
|
||||
var inbox model.Inbox
|
||||
err := s.db.Where("id = ?", inboxID).First(&inbox).Error
|
||||
s.Require().NoError(err)
|
||||
|
||||
conv := &model.Conversation{
|
||||
AccountID: accountID,
|
||||
InboxID: inboxID,
|
||||
ContactID: contactID,
|
||||
Status: status,
|
||||
ChannelType: inbox.ChannelType,
|
||||
}
|
||||
err = s.db.Create(conv).Error
|
||||
s.Require().NoError(err)
|
||||
return conv
|
||||
}
|
||||
|
||||
// MakeRequest sends an HTTP request to the test server.
|
||||
func (s *E2ETestSuite) MakeRequest(method, path string, body interface{}, headers map[string]string) *http.Response {
|
||||
return s.makeRequestWithClient(method, path, body, headers, s.httpClient)
|
||||
}
|
||||
|
||||
// makeRequestWithClient sends an HTTP request using a specific client.
|
||||
func (s *E2ETestSuite) makeRequestWithClient(method, path string, body interface{}, headers map[string]string, client *http.Client) *http.Response {
|
||||
// Implementation left for individual test files
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestE2EHealthCheck verifies the test suite health endpoint works.
|
||||
func TestE2EHealthCheck(t *testing.T) {
|
||||
// Basic sanity check — will be expanded in individual e2e test files
|
||||
}
|
||||
Reference in New Issue
Block a user