tercul-backend/internal/testutil/integration_test_utils.go
Damir Mukimov 24d48396ca
Update GitHub Actions workflows to 2025 best practices (#29)
* Fix workflow triggers to use 'main' branch instead of 'master'

* Switch to semantic version tags for GitHub Actions instead of SHAs for better maintainability

* Fix golangci-lint by adding go mod tidy and specifying paths ./... for linting

* feat: Restructure workflows following Single Responsibility Principle

- Remove old monolithic workflows (ci.yml, ci-cd.yml, cd.yml)
- Add focused workflows: lint.yml, test.yml, build.yml, security.yml, docker-build.yml, deploy.yml
- Each workflow has a single, clear responsibility
- Follow 2025 best practices with semantic versioning, OIDC auth, build attestations
- Add comprehensive README.md with workflow documentation
- Configure Dependabot for automated dependency updates

Workflows now run independently and can be triggered separately for better CI/CD control.

* fix: Resolve CI/CD workflow failures and GraphQL integration test issues

- Fix Application struct mismatch in application_builder.go
- Add global config.Cfg variable and BleveIndexPath field
- Regenerate GraphQL code to fix ProcessArgField errors
- Add search.InitBleve() call in main.go
- Fix all errcheck issues (12 total) in main.go files and test files
- Fix staticcheck issues (deprecated handler.NewDefaultServer, tagged switch)
- Remove all unused code (50 unused items including mock implementations)
- Fix GraphQL 'transport not supported' error in integration tests
- Add comprehensive database cleanup for integration tests
- Update GraphQL server setup with proper error presenter

* feat: Complete backend CI/CD workflow setup

- Add comprehensive GitHub Actions workflows for Go backend
- Build workflow with binary compilation and attestation
- Test workflow with coverage reporting and race detection
- Lint workflow with golangci-lint and security scanning
- Docker build workflow with multi-architecture support
- Deploy workflow for production deployment
- Security workflow with vulnerability scanning
- All workflows follow Single Responsibility Principle
- Use semantic versioning and latest action versions
- Enable security features: OIDC auth, attestations, minimal permissions

* fix: correct Go build path to ./cmd/api

- Fix build workflow to target ./cmd/api instead of ./cmd
- The main.go file is located in cmd/api/ subdirectory

* fix: correct Dockerfile build path to ./cmd/api

- Fix Docker build to target ./cmd/api instead of root directory
- The main.go file is located in cmd/api/ subdirectory
2025-11-27 07:08:08 +01:00

279 lines
9.2 KiB
Go

package testutil
import (
"context"
"log"
"os"
"path/filepath"
"tercul/internal/app"
"tercul/internal/app/analytics"
app_auth "tercul/internal/app/auth"
"tercul/internal/app/author"
"tercul/internal/app/authz"
"tercul/internal/app/book"
"tercul/internal/app/bookmark"
"tercul/internal/app/category"
"tercul/internal/app/collection"
"tercul/internal/app/comment"
"tercul/internal/app/contribution"
"tercul/internal/app/like"
"tercul/internal/app/localization"
app_search "tercul/internal/app/search"
"tercul/internal/app/tag"
"tercul/internal/app/translation"
"tercul/internal/app/user"
"tercul/internal/app/work"
"tercul/internal/data/sql"
"tercul/internal/domain"
"tercul/internal/domain/search"
"tercul/internal/jobs/linguistics"
platform_auth "tercul/internal/platform/auth"
platform_config "tercul/internal/platform/config"
"time"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// mockSearchClient is a mock implementation of the SearchClient interface.
type mockSearchClient struct{}
func (m *mockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pipeline string) error {
return nil
}
// IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories
type IntegrationTestSuite struct {
suite.Suite
App *app.Application
DB *gorm.DB
AdminCtx context.Context
AdminToken string
}
// TestConfig holds configuration for the test environment
type TestConfig struct {
UseInMemoryDB bool // If true, use SQLite in-memory, otherwise use mock repositories
DBPath string // Path for SQLite file (only used if UseInMemoryDB is false)
LogLevel logger.LogLevel
}
// DefaultTestConfig returns a default test configuration
func DefaultTestConfig() *TestConfig {
// Using a file-based DB is more stable for integration tests across multiple packages
// than the in-memory one, which can behave unpredictably with `go test ./...`
return &TestConfig{
UseInMemoryDB: false,
DBPath: "test.db",
LogLevel: logger.Silent,
}
}
// SetupSuite sets up the test suite with the specified configuration
func (s *IntegrationTestSuite) SetupSuite(testConfig *TestConfig) {
if testConfig == nil {
testConfig = DefaultTestConfig()
}
var dbPath string
if !testConfig.UseInMemoryDB && testConfig.DBPath != "" {
// Clean up previous test database file before starting
_ = os.Remove(testConfig.DBPath)
// Ensure directory exists
dir := filepath.Dir(testConfig.DBPath)
if err := os.MkdirAll(dir, 0755); err != nil {
s.T().Fatalf("Failed to create database directory: %v", err)
}
dbPath = testConfig.DBPath
} else {
// Use in-memory database
dbPath = ":memory:"
}
// Custom logger for tests
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: testConfig.LogLevel,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: newLogger,
})
if err != nil {
s.T().Fatalf("Failed to connect to test database: %v", err)
}
s.DB = db
err = db.AutoMigrate(
&domain.Work{}, &domain.User{}, &domain.Author{}, &domain.Translation{},
&domain.Comment{}, &domain.Like{}, &domain.Bookmark{}, &domain.Collection{},
&domain.Tag{}, &domain.Category{}, &domain.Book{}, &domain.Publisher{},
&domain.Source{}, &domain.Copyright{}, &domain.Monetization{},
&domain.WorkStats{}, &domain.Trending{}, &domain.UserSession{}, &domain.Localization{},
&domain.LanguageAnalysis{}, &domain.TextMetadata{}, &domain.ReadabilityScore{},
&domain.TranslationStats{}, &TestEntity{}, &domain.CollectionWork{}, &domain.BookWork{},
)
s.Require().NoError(err, "Failed to migrate database schema")
cfg, err := platform_config.LoadConfig()
if err != nil {
s.T().Fatalf("Failed to load config: %v", err)
}
repos := sql.NewRepositories(s.DB, cfg)
var searchClient search.SearchClient = &mockSearchClient{}
analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB)
sentimentProvider, err := linguistics.NewGoVADERSentimentProvider()
if err != nil {
s.T().Fatalf("Failed to create sentiment provider: %v", err)
}
analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider)
jwtManager := platform_auth.NewJWTManager(cfg)
authzService := authz.NewService(repos.Work, repos.Author, repos.User, repos.Translation)
authorService := author.NewService(repos.Author)
bookService := book.NewService(repos.Book, authzService)
bookmarkService := bookmark.NewService(repos.Bookmark, analyticsService)
categoryService := category.NewService(repos.Category)
collectionService := collection.NewService(repos.Collection)
commentService := comment.NewService(repos.Comment, authzService, analyticsService)
contributionCommands := contribution.NewCommands(repos.Contribution, authzService)
contributionService := contribution.NewService(contributionCommands)
likeService := like.NewService(repos.Like, analyticsService)
tagService := tag.NewService(repos.Tag)
translationService := translation.NewService(repos.Translation, authzService)
userService := user.NewService(repos.User, authzService, repos.UserProfile)
localizationService := localization.NewService(repos.Localization)
authService := app_auth.NewService(repos.User, jwtManager)
workService := work.NewService(repos.Work, repos.Author, repos.User, searchClient, authzService, analyticsService)
searchService := app_search.NewService(searchClient, localizationService)
s.App = app.NewApplication(
authorService,
bookService,
bookmarkService,
categoryService,
collectionService,
commentService,
contributionService,
likeService,
tagService,
translationService,
userService,
localizationService,
authService,
authzService,
workService,
searchService,
analyticsService,
)
}
// TearDownSuite cleans up the test suite
func (s *IntegrationTestSuite) TearDownSuite() {
if s.DB != nil {
sqlDB, err := s.DB.DB()
if err == nil {
_ = sqlDB.Close()
}
}
}
// SetupTest resets test data for each test
func (s *IntegrationTestSuite) SetupTest() {
if s.DB != nil {
// Reset database for each test
s.DB.Exec("DELETE FROM translations")
s.DB.Exec("DELETE FROM works")
s.DB.Exec("DELETE FROM authors")
s.DB.Exec("DELETE FROM users")
s.DB.Exec("DELETE FROM books")
s.DB.Exec("DELETE FROM bookmarks")
s.DB.Exec("DELETE FROM collections")
s.DB.Exec("DELETE FROM comments")
s.DB.Exec("DELETE FROM likes")
s.DB.Exec("DELETE FROM trendings")
s.DB.Exec("DELETE FROM work_stats")
s.DB.Exec("DELETE FROM translation_stats")
s.DB.Exec("DELETE FROM collection_works")
s.DB.Exec("DELETE FROM book_works")
}
// Create a default admin user for tests
adminUser := &domain.User{
Username: "admin",
Email: "admin@test.com",
Role: domain.UserRoleAdmin,
Active: true,
}
_ = adminUser.SetPassword("password")
err := s.DB.Create(adminUser).Error
s.Require().NoError(err)
s.AdminCtx = ContextWithClaims(context.Background(), &platform_auth.Claims{
UserID: adminUser.ID,
Role: string(adminUser.Role),
})
// Generate a token for the admin user
cfg, err := platform_config.LoadConfig()
s.Require().NoError(err)
jwtManager := platform_auth.NewJWTManager(cfg)
token, err := jwtManager.GenerateToken(adminUser)
s.Require().NoError(err)
s.AdminToken = token
}
// CreateTestWork creates a test work with optional content
func (s *IntegrationTestSuite) CreateTestWork(ctx context.Context, title, language string, content string) *domain.Work {
work := &domain.Work{
Title: title,
TranslatableModel: domain.TranslatableModel{
Language: language,
},
}
// Note: CreateWork command might not exist or need context. Assuming it does for now.
// If CreateWork also requires auth, this context should be s.AdminCtx
createdWork, err := s.App.Work.Commands.CreateWork(ctx, work)
s.Require().NoError(err)
if content != "" {
translationInput := translation.CreateOrUpdateTranslationInput{
Title: title,
Content: content,
Language: language,
TranslatableID: createdWork.ID,
TranslatableType: "works",
IsOriginalLanguage: true, // Assuming the first one is original
}
_, err = s.App.Translation.Commands.CreateOrUpdateTranslation(ctx, translationInput)
s.Require().NoError(err)
}
return createdWork
}
// CreateTestTranslation creates a test translation for a work.
func (s *IntegrationTestSuite) CreateTestTranslation(workID uint, language, content string) *domain.Translation {
translationInput := translation.CreateOrUpdateTranslationInput{
Title: "Test Translation",
Content: content,
Language: language,
TranslatableID: workID,
TranslatableType: "works",
}
createdTranslation, err := s.App.Translation.Commands.CreateOrUpdateTranslation(s.AdminCtx, translationInput)
s.Require().NoError(err)
return createdTranslation
}
// ContextWithClaims creates a new context with the given claims.
func ContextWithClaims(ctx context.Context, claims *platform_auth.Claims) context.Context {
return context.WithValue(ctx, platform_auth.ClaimsContextKey, claims)
}