tercul-backend/internal/app/analytics/service.go
google-labs-jules[bot] 952a62c139 test: Increase test coverage for work package to over 80%
This commit increases the test coverage of the `internal/app/work` package from 73.1% to over 80% by adding new tests and fixing a bug discovered during testing.

The following changes were made:
- Added tests for the `ListByCollectionID` query in `queries_test.go`.
- Added a unit test for the `NewService` constructor in `service_test.go`.
- Added tests for authorization, unauthorized access, and other edge cases in the `UpdateWork`, `DeleteWork`, and `MergeWork` commands in `commands_test.go`.
- Fixed a bug in the `mergeWorkStats` function where it was not correctly creating stats for a target work that had no prior stats. This was discovered and fixed as part of writing the new tests.
- Updated the `analytics.Service` interface and its mock implementation to support the bug fix.
2025-10-08 20:45:49 +00:00

371 lines
12 KiB
Go

package analytics
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"tercul/internal/domain"
"tercul/internal/jobs/linguistics"
"tercul/internal/platform/log"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
)
type Service interface {
IncrementWorkViews(ctx context.Context, workID uint) error
IncrementWorkLikes(ctx context.Context, workID uint) error
IncrementWorkComments(ctx context.Context, workID uint) error
IncrementWorkBookmarks(ctx context.Context, workID uint) error
IncrementWorkShares(ctx context.Context, workID uint) error
IncrementWorkTranslationCount(ctx context.Context, workID uint) error
IncrementTranslationViews(ctx context.Context, translationID uint) error
IncrementTranslationLikes(ctx context.Context, translationID uint) error
DecrementWorkLikes(ctx context.Context, workID uint) error
DecrementTranslationLikes(ctx context.Context, translationID uint) error
IncrementTranslationComments(ctx context.Context, translationID uint) error
IncrementTranslationShares(ctx context.Context, translationID uint) error
GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error)
GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error)
UpdateWorkReadingTime(ctx context.Context, workID uint) error
UpdateWorkComplexity(ctx context.Context, workID uint) error
UpdateWorkSentiment(ctx context.Context, workID uint) error
UpdateTranslationReadingTime(ctx context.Context, translationID uint) error
UpdateTranslationSentiment(ctx context.Context, translationID uint) error
UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error
UpdateTrending(ctx context.Context) error
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error)
UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error
}
type service struct {
repo Repository
analysisRepo linguistics.AnalysisRepository
translationRepo domain.TranslationRepository
workRepo domain.WorkRepository
sentimentProvider linguistics.SentimentProvider
tracer trace.Tracer
}
func NewService(repo Repository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo domain.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service {
return &service{
repo: repo,
analysisRepo: analysisRepo,
translationRepo: translationRepo,
workRepo: workRepo,
sentimentProvider: sentimentProvider,
tracer: otel.Tracer("analytics.service"),
}
}
func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkViews")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "views", 1)
}
func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkLikes")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1)
}
func (s *service) DecrementWorkLikes(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "DecrementWorkLikes")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "likes", -1)
}
func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkComments")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1)
}
func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkBookmarks")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1)
}
func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkShares")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1)
}
func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkTranslationCount")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1)
}
func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationViews")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1)
}
func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationLikes")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1)
}
func (s *service) DecrementTranslationLikes(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "DecrementTranslationLikes")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", -1)
}
func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationComments")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1)
}
func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationShares")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1)
}
func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) {
ctx, span := s.tracer.Start(ctx, "GetOrCreateWorkStats")
defer span.End()
return s.repo.GetOrCreateWorkStats(ctx, workID)
}
func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) {
ctx, span := s.tracer.Start(ctx, "GetOrCreateTranslationStats")
defer span.End()
return s.repo.GetOrCreateTranslationStats(ctx, translationID)
}
func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkReadingTime")
defer span.End()
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil {
return err
}
textMetadata, _, _, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil {
return err
}
if textMetadata == nil {
return errors.New("text metadata not found")
}
readingTime := 0
if textMetadata.WordCount > 0 {
readingTime = (textMetadata.WordCount + 199) / 200 // Ceil division
}
stats.ReadingTime = readingTime
return s.repo.UpdateWorkStats(ctx, workID, *stats)
}
func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkComplexity")
defer span.End()
logger := log.FromContext(ctx).With("workID", workID)
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil {
return err
}
_, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil {
logger.Error(err, "could not get readability score for work")
return nil
}
if readabilityScore == nil {
return errors.New("readability score not found")
}
stats.Complexity = readabilityScore.Score
return s.repo.UpdateWorkStats(ctx, workID, *stats)
}
func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkSentiment")
defer span.End()
logger := log.FromContext(ctx).With("workID", workID)
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil {
return err
}
_, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil {
logger.Error(err, "could not get language analysis for work")
return nil
}
if languageAnalysis == nil {
return errors.New("language analysis not found")
}
sentiment, ok := languageAnalysis.Analysis["sentiment"].(float64)
if !ok {
return errors.New("sentiment score not found in language analysis")
}
stats.Sentiment = sentiment
return s.repo.UpdateWorkStats(ctx, workID, *stats)
}
func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateTranslationReadingTime")
defer span.End()
stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID)
if err != nil {
return err
}
translation, err := s.translationRepo.GetByID(ctx, translationID)
if err != nil {
return err
}
if translation == nil {
return errors.New("translation not found")
}
wordCount := len(strings.Fields(translation.Content))
readingTime := 0
if wordCount > 0 {
readingTime = (wordCount + 199) / 200 // Ceil division
}
stats.ReadingTime = readingTime
return s.repo.UpdateTranslationStats(ctx, translationID, *stats)
}
func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateTranslationSentiment")
defer span.End()
stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID)
if err != nil {
return err
}
translation, err := s.translationRepo.GetByID(ctx, translationID)
if err != nil {
return err
}
if translation == nil {
return errors.New("translation not found")
}
sentiment, err := s.sentimentProvider.Score(translation.Content, translation.Language)
if err != nil {
return err
}
stats.Sentiment = sentiment
return s.repo.UpdateTranslationStats(ctx, translationID, *stats)
}
func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error {
ctx, span := s.tracer.Start(ctx, "UpdateUserEngagement")
defer span.End()
today := time.Now().UTC().Truncate(24 * time.Hour)
engagement, err := s.repo.GetOrCreateUserEngagement(ctx, userID, today)
if err != nil {
return err
}
switch eventType {
case "work_read":
engagement.WorksRead++
case "comment_made":
engagement.CommentsMade++
case "like_given":
engagement.LikesGiven++
case "bookmark_made":
engagement.BookmarksMade++
case "translation_made":
engagement.TranslationsMade++
default:
return errors.New("invalid engagement event type")
}
return s.repo.UpdateUserEngagement(ctx, engagement)
}
func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) {
ctx, span := s.tracer.Start(ctx, "GetTrendingWorks")
defer span.End()
return s.repo.GetTrendingWorks(ctx, timePeriod, limit)
}
func (s *service) UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkStats")
defer span.End()
return s.repo.UpdateWorkStats(ctx, workID, stats)
}
func (s *service) UpdateTrending(ctx context.Context) error {
ctx, span := s.tracer.Start(ctx, "UpdateTrending")
defer span.End()
logger := log.FromContext(ctx)
logger.Info("Updating trending works")
works, err := s.workRepo.ListAll(ctx)
if err != nil {
return fmt.Errorf("failed to list works: %w", err)
}
trendingWorks := make([]*domain.Trending, 0, len(works))
for _, aWork := range works {
stats, err := s.repo.GetOrCreateWorkStats(ctx, aWork.ID)
if err != nil {
logger.With("workID", aWork.ID).Error(err, "failed to get work stats")
continue
}
score := float64(stats.Views*1 + stats.Likes*2 + stats.Comments*3)
trendingWorks = append(trendingWorks, &domain.Trending{
EntityType: "Work",
EntityID: aWork.ID,
Score: score,
TimePeriod: "daily", // Hardcoded for now
Date: time.Now().UTC(),
})
}
// Sort by score
sort.Slice(trendingWorks, func(i, j int) bool {
return trendingWorks[i].Score > trendingWorks[j].Score
})
// Get top 10
if len(trendingWorks) > 10 {
trendingWorks = trendingWorks[:10]
}
// Set ranks
for i := range trendingWorks {
trendingWorks[i].Rank = i + 1
}
return s.repo.UpdateTrendingWorks(ctx, "daily", trendingWorks)
}