mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 05:11:34 +00:00
This commit introduces a comprehensive enhancement of the application's analytics features, addressing performance, data modeling, and feature set. The key changes include: - **Performance Improvement:** The analytics repository now uses a database "UPSERT" operation to increment counters, reducing two separate database calls (read and write) into a single, more efficient operation. - **New Metrics:** The `WorkStats` and `TranslationStats` models have been enriched with new, calculated metrics: - `ReadingTime`: An estimation of the time required to read the work or translation. - `Complexity`: A score representing the linguistic complexity of the text. - `Sentiment`: A score indicating the emotional tone of the text. - **Service Refactoring:** The analytics service has been refactored to support the new metrics. It now includes methods to calculate and update these scores, leveraging the existing linguistics package for text analysis. - **GraphQL API Expansion:** The new analytics fields (`readingTime`, `complexity`, `sentiment`) have been exposed through the GraphQL API by updating the `WorkStats` and `TranslationStats` types in the schema. - **Validation and Testing:** - GraphQL input validation has been centralized and improved by moving from ad-hoc checks to a consistent validation pattern in the GraphQL layer. - The test suite has been significantly improved with the addition of new tests for the analytics service and the data access layer, ensuring the correctness and robustness of the new features. This includes fixing several bugs that were discovered during the development process.
450 lines
14 KiB
Go
450 lines
14 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"
|
|
|
|
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
|
|
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
|
|
|
|
// 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
|
|
}
|
|
|
|
// DefaultTestConfig returns a default test configuration
|
|
func DefaultTestConfig() *TestConfig {
|
|
return &TestConfig{
|
|
UseInMemoryDB: true,
|
|
DBPath: "",
|
|
LogLevel: logger.Silent,
|
|
}
|
|
}
|
|
|
|
// SetupSuite sets up the test suite with the specified configuration
|
|
func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) {
|
|
if config == nil {
|
|
config = DefaultTestConfig()
|
|
}
|
|
|
|
if config.UseInMemoryDB {
|
|
s.setupInMemoryDB(config)
|
|
} else {
|
|
s.setupMockRepositories()
|
|
}
|
|
|
|
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, 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)
|
|
|
|
s.App = &app.Application{
|
|
AnalyticsService: s.AnalyticsService,
|
|
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.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.setupTestData()
|
|
} 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
|
|
}
|