diff --git a/internal/data/sql/analytics_repository_test.go b/internal/data/sql/analytics_repository_test.go new file mode 100644 index 0000000..9101cd5 --- /dev/null +++ b/internal/data/sql/analytics_repository_test.go @@ -0,0 +1,204 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/app/analytics" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/platform/config" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// newTestAnalyticsRepoWithSQLite sets up an in-memory SQLite database for testing. +func newTestAnalyticsRepoWithSQLite(t *testing.T) (analytics.Repository, *gorm.DB) { + // Using "file::memory:?cache=shared" to ensure the in-memory database is shared across connections in the same process. + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + + // Auto-migrate the necessary schemas + err = db.AutoMigrate( + &domain.Work{}, + &domain.WorkStats{}, + &domain.Translation{}, + &domain.TranslationStats{}, + &domain.Trending{}, + &domain.UserEngagement{}, + &domain.User{}, + ) + require.NoError(t, err) + + cfg := &config.Config{} + repo := sql.NewAnalyticsRepository(db, cfg) + + // Clean up the database after the test + t.Cleanup(func() { + sqlDB, err := db.DB() + require.NoError(t, err) + err = sqlDB.Close() + require.NoError(t, err) + }) + + return repo, db +} + +func TestAnalyticsRepository_IncrementWorkCounter(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create a work to associate stats with + work := domain.Work{Title: "Test Work"} + require.NoError(t, db.Create(&work).Error) + + t.Run("creates_new_stats_if_not_exist", func(t *testing.T) { + err := repo.IncrementWorkCounter(ctx, work.ID, "views", 5) + require.NoError(t, err) + + var stats domain.WorkStats + err = db.Where("work_id = ?", work.ID).First(&stats).Error + require.NoError(t, err) + assert.Equal(t, int64(5), stats.Views) + }) + + t.Run("increments_existing_stats", func(t *testing.T) { + // Increment again + err := repo.IncrementWorkCounter(ctx, work.ID, "views", 3) + require.NoError(t, err) + + var stats domain.WorkStats + err = db.Where("work_id = ?", work.ID).First(&stats).Error + require.NoError(t, err) + assert.Equal(t, int64(8), stats.Views) // 5 + 3 + }) + + t.Run("invalid_field", func(t *testing.T) { + err := repo.IncrementWorkCounter(ctx, work.ID, "invalid_field", 1) + assert.Error(t, err) + }) +} + +func TestAnalyticsRepository_GetTrendingWorks(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create some works and trending data + work1 := domain.Work{Title: "Trending Work 1"} + work2 := domain.Work{Title: "Trending Work 2"} + require.NoError(t, db.Create(&work1).Error) + require.NoError(t, db.Create(&work2).Error) + + trendingData := []*domain.Trending{ + {EntityID: work1.ID, EntityType: "Work", Rank: 1, TimePeriod: "daily"}, + {EntityID: work2.ID, EntityType: "Work", Rank: 2, TimePeriod: "daily"}, + } + require.NoError(t, db.Create(&trendingData).Error) + + t.Run("success", func(t *testing.T) { + works, err := repo.GetTrendingWorks(ctx, "daily", 5) + require.NoError(t, err) + require.Len(t, works, 2) + assert.Equal(t, work1.ID, works[0].ID) + assert.Equal(t, work2.ID, works[1].ID) + }) +} + +func TestAnalyticsRepository_UpdateTrendingWorks(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create an old trending record + oldTrending := domain.Trending{EntityID: 99, EntityType: "Work", Rank: 1, TimePeriod: "daily"} + require.NoError(t, db.Create(&oldTrending).Error) + + newTrendingData := []*domain.Trending{ + {EntityID: 1, EntityType: "Work", Rank: 1, Score: 100, TimePeriod: "daily"}, + {EntityID: 2, EntityType: "Work", Rank: 2, Score: 90, TimePeriod: "daily"}, + } + + err := repo.UpdateTrendingWorks(ctx, "daily", newTrendingData) + require.NoError(t, err) + + var trendingResult []domain.Trending + db.Where("time_period = ?", "daily").Order("rank asc").Find(&trendingResult) + + require.Len(t, trendingResult, 2) + assert.Equal(t, uint(1), trendingResult[0].EntityID) + assert.Equal(t, uint(2), trendingResult[1].EntityID) +} + +func TestAnalyticsRepository_GetOrCreate(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + work := domain.Work{Title: "Test Work"} + require.NoError(t, db.Create(&work).Error) + translation := domain.Translation{Title: "Test Translation", TranslatableID: work.ID, TranslatableType: "Work"} + require.NoError(t, db.Create(&translation).Error) + user := domain.User{Username: "testuser", Email: "test@test.com"} + require.NoError(t, db.Create(&user).Error) + + t.Run("GetOrCreateWorkStats", func(t *testing.T) { + // Create + stats, err := repo.GetOrCreateWorkStats(ctx, work.ID) + require.NoError(t, err) + assert.Equal(t, work.ID, stats.WorkID) + + // Get + stats2, err := repo.GetOrCreateWorkStats(ctx, work.ID) + require.NoError(t, err) + assert.Equal(t, stats.ID, stats2.ID) + }) + + t.Run("GetOrCreateTranslationStats", func(t *testing.T) { + // Create + stats, err := repo.GetOrCreateTranslationStats(ctx, translation.ID) + require.NoError(t, err) + assert.Equal(t, translation.ID, stats.TranslationID) + + // Get + stats2, err := repo.GetOrCreateTranslationStats(ctx, translation.ID) + require.NoError(t, err) + assert.Equal(t, stats.ID, stats2.ID) + }) + + t.Run("GetOrCreateUserEngagement", func(t *testing.T) { + date := time.Now().Truncate(24 * time.Hour) + // Create + eng, err := repo.GetOrCreateUserEngagement(ctx, user.ID, date) + require.NoError(t, err) + assert.Equal(t, user.ID, eng.UserID) + + // Get + eng2, err := repo.GetOrCreateUserEngagement(ctx, user.ID, date) + require.NoError(t, err) + assert.Equal(t, eng.ID, eng2.ID) + }) +} + +func TestAnalyticsRepository_UpdateUserEngagement(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + user := domain.User{Username: "testuser", Email: "test@test.com"} + require.NoError(t, db.Create(&user).Error) + date := time.Now().Truncate(24 * time.Hour) + engagement, err := repo.GetOrCreateUserEngagement(ctx, user.ID, date) + require.NoError(t, err) + + engagement.LikesGiven = 15 + engagement.WorksRead = 10 + + err = repo.UpdateUserEngagement(ctx, engagement) + require.NoError(t, err) + + var updatedEngagement domain.UserEngagement + db.First(&updatedEngagement, engagement.ID) + + assert.Equal(t, 15, updatedEngagement.LikesGiven) + assert.Equal(t, 10, updatedEngagement.WorksRead) +} \ No newline at end of file diff --git a/internal/data/sql/auth_repository_test.go b/internal/data/sql/auth_repository_test.go new file mode 100644 index 0000000..4a4da96 --- /dev/null +++ b/internal/data/sql/auth_repository_test.go @@ -0,0 +1,91 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/platform/config" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// newTestAuthRepoWithSQLite sets up an in-memory SQLite database for auth repository testing. +func newTestAuthRepoWithSQLite(t *testing.T) (domain.AuthRepository, *gorm.DB) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + + // Auto-migrate the necessary schemas + err = db.AutoMigrate( + &domain.User{}, + &domain.UserSession{}, + ) + require.NoError(t, err) + + cfg := &config.Config{} + repo := sql.NewAuthRepository(db, cfg) + + // Clean up the database after the test + t.Cleanup(func() { + sqlDB, err := db.DB() + require.NoError(t, err) + err = sqlDB.Close() + require.NoError(t, err) + }) + + return repo, db +} + +func TestAuthRepository_StoreToken(t *testing.T) { + repo, db := newTestAuthRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create a user + user := domain.User{Username: "authuser", Email: "auth@test.com"} + require.NoError(t, db.Create(&user).Error) + + token := "my-secret-token" + expiresAt := time.Now().Add(1 * time.Hour) + + err := repo.StoreToken(ctx, user.ID, token, expiresAt) + require.NoError(t, err) + + var session domain.UserSession + err = db.Where("token = ?", token).First(&session).Error + require.NoError(t, err) + + assert.Equal(t, user.ID, session.UserID) + assert.Equal(t, token, session.Token) + // Truncate to a reasonable precision for comparison + assert.WithinDuration(t, expiresAt, session.ExpiresAt, time.Second) +} + +func TestAuthRepository_DeleteToken(t *testing.T) { + repo, db := newTestAuthRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create a user and a session + user := domain.User{Username: "authuser2", Email: "auth2@test.com"} + require.NoError(t, db.Create(&user).Error) + token := "token-to-delete" + session := &domain.UserSession{ + UserID: user.ID, + Token: token, + ExpiresAt: time.Now().Add(1 * time.Hour), + } + require.NoError(t, db.Create(session).Error) + + // Delete the token + err := repo.DeleteToken(ctx, token) + require.NoError(t, err) + + // Verify it's gone + var deletedSession domain.UserSession + err = db.Where("token = ?", token).First(&deletedSession).Error + assert.Error(t, err) + assert.Equal(t, gorm.ErrRecordNotFound, err) +} \ No newline at end of file diff --git a/internal/data/sql/copyright_claim_repository_test.go b/internal/data/sql/copyright_claim_repository_test.go new file mode 100644 index 0000000..2f31aa7 --- /dev/null +++ b/internal/data/sql/copyright_claim_repository_test.go @@ -0,0 +1,122 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/platform/config" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// newTestCopyrightClaimRepoWithSQLite sets up an in-memory SQLite database for copyright claim repository testing. +func newTestCopyrightClaimRepoWithSQLite(t *testing.T) (domain.CopyrightClaimRepository, *gorm.DB) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + + // Auto-migrate the necessary schemas + err = db.AutoMigrate( + &domain.User{}, + &domain.Work{}, + &domain.CopyrightClaim{}, + ) + require.NoError(t, err) + + cfg := &config.Config{} + repo := sql.NewCopyrightClaimRepository(db, cfg) + + // Clean up the database after the test + t.Cleanup(func() { + sqlDB, err := db.DB() + require.NoError(t, err) + err = sqlDB.Close() + require.NoError(t, err) + }) + + return repo, db +} + +func TestCopyrightClaimRepository_ListByWorkID(t *testing.T) { + repo, db := newTestCopyrightClaimRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create a user and a work + user := domain.User{Username: "claimuser", Email: "claim@test.com"} + require.NoError(t, db.Create(&user).Error) + work1 := domain.Work{Title: "Work 1"} + require.NoError(t, db.Create(&work1).Error) + work2 := domain.Work{Title: "Work 2"} + require.NoError(t, db.Create(&work2).Error) + + // Create some claims + claim1 := domain.CopyrightClaim{UserID: &user.ID, WorkID: &work1.ID, Details: "Claim 1", ClaimDate: time.Now()} + claim2 := domain.CopyrightClaim{UserID: &user.ID, WorkID: &work1.ID, Details: "Claim 2", ClaimDate: time.Now()} + claim3 := domain.CopyrightClaim{UserID: &user.ID, WorkID: &work2.ID, Details: "Claim 3", ClaimDate: time.Now()} + require.NoError(t, db.Create(&claim1).Error) + require.NoError(t, db.Create(&claim2).Error) + require.NoError(t, db.Create(&claim3).Error) + + t.Run("finds_claims_for_work1", func(t *testing.T) { + claims, err := repo.ListByWorkID(ctx, work1.ID) + require.NoError(t, err) + assert.Len(t, claims, 2) + }) + + t.Run("finds_claims_for_work2", func(t *testing.T) { + claims, err := repo.ListByWorkID(ctx, work2.ID) + require.NoError(t, err) + assert.Len(t, claims, 1) + assert.Equal(t, "Claim 3", claims[0].Details) + }) + + t.Run("returns_empty_slice_for_no_claims", func(t *testing.T) { + claims, err := repo.ListByWorkID(ctx, 999) // Non-existent work + require.NoError(t, err) + assert.Empty(t, claims) + }) +} + +func TestCopyrightClaimRepository_ListByUserID(t *testing.T) { + repo, db := newTestCopyrightClaimRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create users and a work + user1 := domain.User{Username: "user1", Email: "user1@test.com"} + require.NoError(t, db.Create(&user1).Error) + user2 := domain.User{Username: "user2", Email: "user2@test.com"} + require.NoError(t, db.Create(&user2).Error) + work := domain.Work{Title: "Test Work"} + require.NoError(t, db.Create(&work).Error) + + // Create some claims + claim1 := domain.CopyrightClaim{UserID: &user1.ID, WorkID: &work.ID, Details: "Claim 1", ClaimDate: time.Now()} + claim2 := domain.CopyrightClaim{UserID: &user2.ID, WorkID: &work.ID, Details: "Claim 2", ClaimDate: time.Now()} + claim3 := domain.CopyrightClaim{UserID: &user2.ID, WorkID: &work.ID, Details: "Claim 3", ClaimDate: time.Now()} + require.NoError(t, db.Create(&claim1).Error) + require.NoError(t, db.Create(&claim2).Error) + require.NoError(t, db.Create(&claim3).Error) + + t.Run("finds_claims_for_user1", func(t *testing.T) { + claims, err := repo.ListByUserID(ctx, user1.ID) + require.NoError(t, err) + assert.Len(t, claims, 1) + assert.Equal(t, "Claim 1", claims[0].Details) + }) + + t.Run("finds_claims_for_user2", func(t *testing.T) { + claims, err := repo.ListByUserID(ctx, user2.ID) + require.NoError(t, err) + assert.Len(t, claims, 2) + }) + + t.Run("returns_empty_slice_for_no_claims", func(t *testing.T) { + claims, err := repo.ListByUserID(ctx, 999) // Non-existent user + require.NoError(t, err) + assert.Empty(t, claims) + }) +} \ No newline at end of file diff --git a/internal/domain/entities.go b/internal/domain/entities.go index f59d1e9..792bbaf 100644 --- a/internal/domain/entities.go +++ b/internal/domain/entities.go @@ -581,8 +581,10 @@ type CopyrightClaim struct { ClaimDate time.Time `gorm:"not null"` Resolution string `gorm:"type:text"` ResolvedAt *time.Time - UserID *uint - User *User `gorm:"foreignKey:UserID"` + UserID *uint + User *User `gorm:"foreignKey:UserID"` + WorkID *uint + Work *Work `gorm:"foreignKey:WorkID"` } type MonetizationType string const (