tercul-backend/internal/testutil/integration_test_utils.go
google-labs-jules[bot] 04878c7bec feat: Implement event-driven analytics system
This commit introduces a new event-driven analytics system to track user interactions with works and translations. The system is designed to be scalable and production-ready.

Key changes:
- An asynchronous event-driven architecture using `asynq` for handling analytics.
- A new background worker process (`cmd/worker`) to process analytics events from a Redis-backed queue.
- GraphQL resolvers now publish `AnalyticsEvent`s to the queue instead of directly calling the analytics service.
- New `popularTranslations` GraphQL query to leverage the new analytics data.
- Integration tests now use `miniredis` to mock Redis, making them self-contained.
- The `TODO.md` file has been updated to reflect the completed work.
2025-09-07 22:54:43 +00:00

481 lines
15 KiB
Go

package testutil
import (
"context"
"log"
"os"
"path/filepath"
"time"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/alicebob/miniredis/v2"
"github.com/hibiken/asynq"
graph "tercul/internal/adapters/graphql"
"tercul/internal/app/auth"
auth_platform "tercul/internal/platform/auth"
"tercul/internal/app"
"tercul/internal/app/copyright"
"tercul/internal/app/localization"
"tercul/internal/app/analytics"
"tercul/internal/app/monetization"
"tercul/internal/app/search"
"tercul/internal/app/work"
"tercul/internal/data/sql"
"tercul/internal/domain"
"tercul/internal/jobs/linguistics"
)
// 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
AsynqClient *asynq.Client
Config *TestConfig
miniRedis *miniredis.Miniredis
WorkRepo domain.WorkRepository
UserRepo domain.UserRepository
AuthorRepo domain.AuthorRepository
TranslationRepo domain.TranslationRepository
CommentRepo domain.CommentRepository
LikeRepo domain.LikeRepository
BookmarkRepo domain.BookmarkRepository
CollectionRepo domain.CollectionRepository
TagRepo domain.TagRepository
CategoryRepo domain.CategoryRepository
BookRepo domain.BookRepository
MonetizationRepo domain.MonetizationRepository
PublisherRepo domain.PublisherRepository
SourceRepo domain.SourceRepository
CopyrightRepo domain.CopyrightRepository
AnalyticsRepo domain.AnalyticsRepository
AnalysisRepo linguistics.AnalysisRepository
// Services
WorkCommands *work.WorkCommands
WorkQueries *work.WorkQueries
Localization localization.Service
AuthCommands *auth.AuthCommands
AuthQueries *auth.AuthQueries
AnalyticsService analytics.Service
Ctx context.Context
// Test data
TestWorks []*domain.Work
TestUsers []*domain.User
TestAuthors []*domain.Author
TestTranslations []*domain.Translation
}
// 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
RedisAddr string
}
// DefaultTestConfig returns a default test configuration
func DefaultTestConfig() *TestConfig {
redisAddr := os.Getenv("REDIS_ADDR")
if redisAddr == "" {
redisAddr = "localhost:6379"
}
return &TestConfig{
UseInMemoryDB: true,
DBPath: "",
LogLevel: logger.Silent,
RedisAddr: redisAddr,
}
}
// SetupSuite sets up the test suite with the specified configuration
func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) {
if config == nil {
config = DefaultTestConfig()
}
s.Config = config
if config.UseInMemoryDB {
s.setupInMemoryDB(config)
} else {
s.setupMockRepositories()
}
mr, err := miniredis.Run()
if err != nil {
s.T().Fatalf("an error '%s' was not expected when starting miniredis", err)
}
s.miniRedis = mr
config.RedisAddr = mr.Addr()
s.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
Addr: config.RedisAddr,
})
s.setupServices()
s.setupTestData()
}
// setupInMemoryDB sets up an in-memory SQLite database for testing
func (s *IntegrationTestSuite) setupInMemoryDB(config *TestConfig) {
var dbPath string
if config.DBPath != "" {
// Ensure directory exists
dir := filepath.Dir(config.DBPath)
if err := os.MkdirAll(dir, 0755); err != nil {
s.T().Fatalf("Failed to create database directory: %v", err)
}
dbPath = config.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: config.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
// Run migrations
if err := db.AutoMigrate(
&domain.Work{},
&domain.User{},
&domain.Author{},
&domain.Translation{},
&domain.Comment{},
&domain.Like{},
&domain.Bookmark{},
&domain.Collection{},
&domain.Tag{},
&domain.Category{},
&domain.Country{},
&domain.City{},
&domain.Place{},
&domain.Address{},
&domain.Copyright{},
&domain.CopyrightClaim{},
&domain.Monetization{},
&domain.Book{},
&domain.Publisher{},
&domain.Source{},
&domain.WorkCopyright{},
&domain.AuthorCopyright{},
&domain.BookCopyright{},
&domain.PublisherCopyright{},
&domain.SourceCopyright{},
&domain.WorkMonetization{},
&domain.AuthorMonetization{},
&domain.BookMonetization{},
&domain.PublisherMonetization{},
&domain.SourceMonetization{},
&domain.WorkStats{},
&domain.TranslationStats{},
// &domain.WorkAnalytics{}, // Commented out as it's not in models package
&domain.ReadabilityScore{},
&domain.WritingStyle{},
&domain.Emotion{},
&domain.TopicCluster{},
&domain.Mood{},
&domain.Concept{},
&domain.LinguisticLayer{},
&domain.WorkStats{},
&domain.TranslationStats{},
&domain.UserEngagement{},
&domain.Trending{},
&domain.TextMetadata{},
&domain.PoeticAnalysis{},
&domain.LanguageAnalysis{},
&domain.TranslationField{},
&TestEntity{}, // Add TestEntity for generic repository tests
); err != nil {
s.T().Fatalf("Failed to run migrations: %v", err)
}
// Create repository instances
s.WorkRepo = sql.NewWorkRepository(db)
s.UserRepo = sql.NewUserRepository(db)
s.AuthorRepo = sql.NewAuthorRepository(db)
s.TranslationRepo = sql.NewTranslationRepository(db)
s.CommentRepo = sql.NewCommentRepository(db)
s.LikeRepo = sql.NewLikeRepository(db)
s.BookmarkRepo = sql.NewBookmarkRepository(db)
s.CollectionRepo = sql.NewCollectionRepository(db)
s.TagRepo = sql.NewTagRepository(db)
s.CategoryRepo = sql.NewCategoryRepository(db)
s.BookRepo = sql.NewBookRepository(db)
s.MonetizationRepo = sql.NewMonetizationRepository(db)
s.PublisherRepo = sql.NewPublisherRepository(db)
s.SourceRepo = sql.NewSourceRepository(db)
s.CopyrightRepo = sql.NewCopyrightRepository(db)
s.AnalyticsRepo = sql.NewAnalyticsRepository(db)
s.AnalysisRepo = linguistics.NewGORMAnalysisRepository(db)
}
// setupMockRepositories sets up mock repositories for testing
func (s *IntegrationTestSuite) setupMockRepositories() {
s.WorkRepo = NewUnifiedMockWorkRepository()
// Temporarily comment out problematic repositories until we fix the interface implementations
// s.UserRepo = NewMockUserRepository()
// s.AuthorRepo = NewMockAuthorRepository()
// s.TranslationRepo = NewMockTranslationRepository()
// s.CommentRepo = NewMockCommentRepository()
// s.LikeRepo = NewMockLikeRepository()
// s.BookmarkRepo = NewMockBookmarkRepository()
// s.CollectionRepo = NewMockCollectionRepository()
// s.TagRepo = NewMockTagRepository()
// s.CategoryRepo = NewMockCategoryRepository()
}
// setupServices sets up service instances
func (s *IntegrationTestSuite) setupServices() {
mockAnalyzer := &MockAnalyzer{}
s.WorkCommands = work.NewWorkCommands(s.WorkRepo, mockAnalyzer)
s.WorkQueries = work.NewWorkQueries(s.WorkRepo)
s.Localization = localization.NewService(s.TranslationRepo)
jwtManager := auth_platform.NewJWTManager()
s.AuthCommands = auth.NewAuthCommands(s.UserRepo, jwtManager)
s.AuthQueries = auth.NewAuthQueries(s.UserRepo, jwtManager)
sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider()
s.AnalyticsService = analytics.NewService(s.AnalyticsRepo, s.AnalysisRepo, s.TranslationRepo, s.WorkRepo, sentimentProvider)
copyrightCommands := copyright.NewCopyrightCommands(s.CopyrightRepo)
copyrightQueries := copyright.NewCopyrightQueries(s.CopyrightRepo, s.WorkRepo, s.AuthorRepo, s.BookRepo, s.PublisherRepo, s.SourceRepo)
monetizationCommands := monetization.NewMonetizationCommands(s.MonetizationRepo)
monetizationQueries := monetization.NewMonetizationQueries(s.MonetizationRepo, s.WorkRepo, s.AuthorRepo, s.BookRepo, s.PublisherRepo, s.SourceRepo)
analyticsPublisher := analytics.NewEventPublisher(s.AsynqClient)
s.App = &app.Application{
AnalyticsService: s.AnalyticsService,
AnalyticsPublisher: analyticsPublisher,
WorkCommands: s.WorkCommands,
WorkQueries: s.WorkQueries,
AuthCommands: s.AuthCommands,
AuthQueries: s.AuthQueries,
CopyrightCommands: copyrightCommands,
CopyrightQueries: copyrightQueries,
Localization: s.Localization,
Search: search.NewIndexService(s.Localization, &MockWeaviateWrapper{}),
MonetizationCommands: monetizationCommands,
MonetizationQueries: monetizationQueries,
AuthorRepo: s.AuthorRepo,
UserRepo: s.UserRepo,
TagRepo: s.TagRepo,
CategoryRepo: s.CategoryRepo,
BookRepo: s.BookRepo,
PublisherRepo: s.PublisherRepo,
SourceRepo: s.SourceRepo,
TranslationRepo: s.TranslationRepo,
CopyrightRepo: s.CopyrightRepo,
MonetizationRepo: s.MonetizationRepo,
CommentRepo: s.CommentRepo,
LikeRepo: s.LikeRepo,
BookmarkRepo: s.BookmarkRepo,
CollectionRepo: s.CollectionRepo,
}
}
// setupTestData creates initial test data
func (s *IntegrationTestSuite) setupTestData() {
// Create test users
s.TestUsers = []*domain.User{
{Username: "testuser1", Email: "test1@example.com", FirstName: "Test", LastName: "User1"},
{Username: "testuser2", Email: "test2@example.com", FirstName: "Test", LastName: "User2"},
}
for _, user := range s.TestUsers {
if err := s.UserRepo.Create(context.Background(), user); err != nil {
s.T().Logf("Warning: Failed to create test user: %v", err)
}
}
// Create test authors
s.TestAuthors = []*domain.Author{
{Name: "Test Author 1", TranslatableModel: domain.TranslatableModel{Language: "en"}},
{Name: "Test Author 2", TranslatableModel: domain.TranslatableModel{Language: "fr"}},
}
for _, author := range s.TestAuthors {
if err := s.AuthorRepo.Create(context.Background(), author); err != nil {
s.T().Logf("Warning: Failed to create test author: %v", err)
}
}
// Create test works
s.TestWorks = []*domain.Work{
{Title: "Test Work 1", TranslatableModel: domain.TranslatableModel{Language: "en"}},
{Title: "Test Work 2", TranslatableModel: domain.TranslatableModel{Language: "en"}},
{Title: "Test Work 3", TranslatableModel: domain.TranslatableModel{Language: "fr"}},
}
for _, work := range s.TestWorks {
if err := s.WorkRepo.Create(context.Background(), work); err != nil {
s.T().Logf("Warning: Failed to create test work: %v", err)
}
}
// Create test translations
s.TestTranslations = []*domain.Translation{
{
Title: "Test Work 1",
Content: "Test content for work 1",
Language: "en",
TranslatableID: s.TestWorks[0].ID,
TranslatableType: "Work",
IsOriginalLanguage: true,
},
{
Title: "Test Work 2",
Content: "Test content for work 2",
Language: "en",
TranslatableID: s.TestWorks[1].ID,
TranslatableType: "Work",
IsOriginalLanguage: true,
},
{
Title: "Test Work 3",
Content: "Test content for work 3",
Language: "fr",
TranslatableID: s.TestWorks[2].ID,
TranslatableType: "Work",
IsOriginalLanguage: true,
},
}
for _, translation := range s.TestTranslations {
if err := s.TranslationRepo.Create(context.Background(), translation); err != nil {
s.T().Logf("Warning: Failed to create test translation: %v", err)
}
}
}
// TearDownSuite cleans up the test suite
func (s *IntegrationTestSuite) TearDownSuite() {
if s.miniRedis != nil {
s.miniRedis.Close()
}
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")
} else {
// Reset mock repositories
if mockRepo, ok := s.WorkRepo.(*UnifiedMockWorkRepository); ok {
mockRepo.Reset()
}
// Add similar reset logic for other mock repositories
}
}
// GetResolver returns a properly configured GraphQL resolver for testing
func (s *IntegrationTestSuite) GetResolver() *graph.Resolver {
return &graph.Resolver{
App: s.App,
}
}
// CreateTestWork creates a test work with optional content
func (s *IntegrationTestSuite) CreateTestWork(title, language string, content string) *domain.Work {
work := &domain.Work{
Title: title,
TranslatableModel: domain.TranslatableModel{Language: language},
}
if err := s.WorkRepo.Create(context.Background(), work); err != nil {
s.T().Fatalf("Failed to create test work: %v", err)
}
if content != "" {
translation := &domain.Translation{
Title: title,
Content: content,
Language: language,
TranslatableID: work.ID,
TranslatableType: "Work",
IsOriginalLanguage: true,
}
if err := s.TranslationRepo.Create(context.Background(), translation); err != nil {
s.T().Logf("Warning: Failed to create test translation: %v", err)
}
}
return work
}
// CleanupTestData removes all test data
func (s *IntegrationTestSuite) CleanupTestData() {
if s.DB != nil {
s.DB.Exec("DELETE FROM translations")
s.DB.Exec("DELETE FROM works")
s.DB.Exec("DELETE FROM authors")
s.DB.Exec("DELETE FROM users")
}
}
// CreateAuthenticatedUser creates a user and returns the user and an auth token
func (s *IntegrationTestSuite) CreateAuthenticatedUser(username, email string, role domain.UserRole) (*domain.User, string) {
user := &domain.User{
Username: username,
Email: email,
Role: role,
Password: "password", // Not used for token generation, but good to have
}
err := s.UserRepo.Create(context.Background(), user)
s.Require().NoError(err)
jwtManager := auth_platform.NewJWTManager()
token, err := jwtManager.GenerateToken(user)
s.Require().NoError(err)
return user, token
}
// CreateTestTranslation creates a test translation for a work
func (s *IntegrationTestSuite) CreateTestTranslation(workID uint, language, content string) *domain.Translation {
translation := &domain.Translation{
Title: "Test Translation",
Content: content,
Language: language,
TranslatableID: workID,
TranslatableType: "Work",
}
err := s.TranslationRepo.Create(context.Background(), translation)
s.Require().NoError(err)
return translation
}