mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 05:11:34 +00:00
This commit significantly increases the test coverage across the application and fixes several underlying bugs that were discovered while writing the new tests. The key changes include: - **New Tests:** Added extensive integration and unit tests for GraphQL resolvers, application services, and data repositories, substantially increasing the test coverage for packages like `graphql`, `user`, `translation`, and `analytics`. - **Authorization Bug Fixes:** - Fixed a critical bug where a user creating a `Work` was not correctly associated as its author, causing subsequent permission failures. - Corrected the authorization logic in `authz.Service` to properly check for entity ownership by non-admin users. - **Test Refactoring:** - Refactored numerous test suites to use `testify/mock` instead of manual mocks, improving test clarity and maintainability. - Isolated integration tests by creating a fresh admin user and token for each test run, eliminating test pollution. - Centralized domain errors into `internal/domain/errors.go` and updated repositories to use them, making error handling more consistent. - **Code Quality Improvements:** - Replaced manual mock implementations with `testify/mock` for better consistency. - Cleaned up redundant and outdated test files. These changes stabilize the test suite, improve the overall quality of the codebase, and move the project closer to the goal of 80% test coverage.
272 lines
8.9 KiB
Go
272 lines
8.9 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 trendings")
|
|
s.DB.Exec("DELETE FROM work_stats")
|
|
s.DB.Exec("DELETE FROM translation_stats")
|
|
}
|
|
|
|
// 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)
|
|
} |