From 4b1fc7ccc3c2fe427fbfb1740549346ed6e8f93c Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Wed, 17 Dec 2025 10:25:18 +0300 Subject: [PATCH 1/2] add results service --- cmd/results/main.go | 32 + internal/results/config/config.go | 80 ++ internal/results/domain/errors.go | 11 + internal/results/domain/result.go | 45 + internal/results/domain/result_test.go | 332 +++++++ internal/results/handler/grpc/results.go | 309 +++++++ internal/results/handler/grpc/results_test.go | 653 ++++++++++++++ .../results/repository/postgres/result.go | 429 +++++++++ internal/results/repository/result.go | 30 + internal/results/server/server.go | 102 +++ internal/results/service/result.go | 97 +++ internal/results/service/result_test.go | 815 ++++++++++++++++++ 12 files changed, 2935 insertions(+) create mode 100644 cmd/results/main.go create mode 100644 internal/results/config/config.go create mode 100644 internal/results/domain/errors.go create mode 100644 internal/results/domain/result.go create mode 100644 internal/results/domain/result_test.go create mode 100644 internal/results/handler/grpc/results.go create mode 100644 internal/results/handler/grpc/results_test.go create mode 100644 internal/results/repository/postgres/result.go create mode 100644 internal/results/repository/result.go create mode 100644 internal/results/server/server.go create mode 100644 internal/results/service/result.go create mode 100644 internal/results/service/result_test.go diff --git a/cmd/results/main.go b/cmd/results/main.go new file mode 100644 index 0000000..c89784a --- /dev/null +++ b/cmd/results/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "datarush/internal/results/config" + "datarush/internal/results/server" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + srv := server.New(cfg) + + if err := srv.Start(); err != nil { + log.Fatalf("failed to start server: %v", err) + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("shutting down results server...") + srv.Stop() + log.Println("results server stopped") +} \ No newline at end of file diff --git a/internal/results/config/config.go b/internal/results/config/config.go new file mode 100644 index 0000000..7144dea --- /dev/null +++ b/internal/results/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "fmt" + "log" + "net" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + GRPCPort int + GRPCEnableReflection bool + HTTPPort int + LogLevel string + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + UserServiceAddr string + SubmissionServiceAddr string + TaskServiceAddr string +} + +func Load() (*Config, error) { + _ = godotenv.Load() + + return &Config{ + GRPCPort: mustGetInt("RESULTS_GRPC_PORT", 50054), + GRPCEnableReflection: mustGetBool("RESULTS_GRPC_ENABLE_REFLECTION", false), + HTTPPort: mustGetInt("RESULTS_HTTP_PORT", 8083), + LogLevel: getEnv("LOG_LEVEL", "info"), + DBHost: getEnv("POSTGRES_HOST", "localhost"), + DBPort: mustGetInt("POSTGRES_PORT", 5432), + DBUser: getEnv("POSTGRES_USERNAME", "postgres"), + DBPassword: getEnv("POSTGRES_PASSWORD", "postgres"), + DBName: getEnv("POSTGRES_DATABASE", "postgres"), + UserServiceAddr: getEnv("USER_GRPC_ADDR", "localhost:50051"), + SubmissionServiceAddr: getEnv("SUBMISSION_GRPC_ADDR", "localhost:50055"), + TaskServiceAddr: getEnv("TASK_GRPC_ADDR", "localhost:50056"), + }, nil +} + +func getEnv(key, def string) string { + if val := os.Getenv(key); val != "" { + return val + } + return def +} + +func mustGetInt(key string, def int) int { + val := getEnv(key, strconv.Itoa(def)) + n, err := strconv.Atoi(val) + if err != nil { + log.Fatalf("invalid int for %s: %v", key, err) + } + return n +} + +func mustGetBool(key string, def bool) bool { + val := getEnv(key, strconv.FormatBool(def)) + b, err := strconv.ParseBool(val) + if err != nil { + log.Fatalf("invalid bool for %s: %v", key, err) + } + return b +} + +func (c Config) BuildPostgresConnStr() string { + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName) +} + +func (c Config) BuildPostgresDSN() string { + return fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", + c.DBUser, c.DBPassword, net.JoinHostPort(c.DBHost, strconv.Itoa(c.DBPort)), c.DBName) +} \ No newline at end of file diff --git a/internal/results/domain/errors.go b/internal/results/domain/errors.go new file mode 100644 index 0000000..c8a855c --- /dev/null +++ b/internal/results/domain/errors.go @@ -0,0 +1,11 @@ +package domain + +import "errors" + +var ( + ErrResultNotFound = errors.New("result not found") + ErrInvalidResultData = errors.New("invalid result data") + ErrDuplicateResult = errors.New("duplicate result") + ErrCompetitionNotFound = errors.New("competition not found") + ErrUserNotFound = errors.New("user not found") +) \ No newline at end of file diff --git a/internal/results/domain/result.go b/internal/results/domain/result.go new file mode 100644 index 0000000..a1b6672 --- /dev/null +++ b/internal/results/domain/result.go @@ -0,0 +1,45 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +type ResultStatus string + +const ( + ResultStatusUnspecified ResultStatus = "UNSPECIFIED" + ResultStatusPending ResultStatus = "PENDING" + ResultStatusProcessing ResultStatus = "PROCESSING" + ResultStatusCompleted ResultStatus = "COMPLETED" + ResultStatusFailed ResultStatus = "FAILED" +) + +type Result struct { + ID uuid.UUID + CompetitionID uuid.UUID + UserID uuid.UUID + Score float64 + Rank *int + Status ResultStatus + SubmissionID *uuid.UUID + Metadata map[string]interface{} + CreatedAt time.Time + UpdatedAt time.Time +} + +func NewResult(id uuid.UUID, competitionID uuid.UUID, userID uuid.UUID, score float64, rank *int, status ResultStatus, submissionID *uuid.UUID, metadata map[string]interface{}, createdAt time.Time, updatedAt time.Time) *Result { + return &Result{ + ID: id, + CompetitionID: competitionID, + UserID: userID, + Score: score, + Rank: rank, + Status: status, + SubmissionID: submissionID, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} \ No newline at end of file diff --git a/internal/results/domain/result_test.go b/internal/results/domain/result_test.go new file mode 100644 index 0000000..98407bf --- /dev/null +++ b/internal/results/domain/result_test.go @@ -0,0 +1,332 @@ +package domain + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestNewResult(t *testing.T) { + id := uuid.New() + competitionID := uuid.New() + userID := uuid.New() + submissionID := uuid.New() + now := time.Now() + rank := 1 + + result := NewResult( + id, + competitionID, + userID, + 95.5, + &rank, + ResultStatusCompleted, + &submissionID, + map[string]interface{}{"notes": "test"}, + now, + now, + ) + + if result.ID != id { + t.Errorf("Expected ID %v, got %v", id, result.ID) + } + if result.CompetitionID != competitionID { + t.Errorf("Expected CompetitionID %v, got %v", competitionID, result.CompetitionID) + } + if result.UserID != userID { + t.Errorf("Expected UserID %v, got %v", userID, result.UserID) + } + if result.Score != 95.5 { + t.Errorf("Expected Score 95.5, got %v", result.Score) + } + if result.Rank == nil || *result.Rank != 1 { + t.Errorf("Expected Rank 1, got %v", result.Rank) + } + if result.Status != ResultStatusCompleted { + t.Errorf("Expected Status %v, got %v", ResultStatusCompleted, result.Status) + } + if result.SubmissionID == nil || *result.SubmissionID != submissionID { + t.Errorf("Expected SubmissionID %v, got %v", submissionID, result.SubmissionID) + } + if len(result.Metadata) != 1 { + t.Errorf("Expected Metadata with 1 item, got %d", len(result.Metadata)) + } +} + +func TestResultStatusConstants(t *testing.T) { + tests := []struct { + status ResultStatus + expected string + }{ + {ResultStatusUnspecified, "UNSPECIFIED"}, + {ResultStatusPending, "PENDING"}, + {ResultStatusProcessing, "PROCESSING"}, + {ResultStatusCompleted, "COMPLETED"}, + {ResultStatusFailed, "FAILED"}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + if string(tt.status) != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, tt.status) + } + }) + } +} + +func TestResultWithNilValues(t *testing.T) { + id := uuid.New() + competitionID := uuid.New() + userID := uuid.New() + now := time.Now() + + result := NewResult( + id, + competitionID, + userID, + 0.0, + nil, + ResultStatusPending, + nil, + nil, + now, + now, + ) + + if result.Rank != nil { + t.Errorf("Expected Rank to be nil, got %v", result.Rank) + } + if result.SubmissionID != nil { + t.Errorf("Expected SubmissionID to be nil, got %v", result.SubmissionID) + } + if result.Metadata != nil { + t.Errorf("Expected Metadata to be nil, got %v", result.Metadata) + } +} + +func TestResultMetadata(t *testing.T) { + id := uuid.New() + competitionID := uuid.New() + userID := uuid.New() + now := time.Now() + + metadata := map[string]interface{}{ + "attempt": 3, + "time_spent": 3600, + "notes": "First submission", + "tags": []string{"urgent", "review"}, + } + + result := NewResult( + id, + competitionID, + userID, + 95.5, + nil, + ResultStatusCompleted, + nil, + metadata, + now, + now, + ) + + if len(result.Metadata) != 4 { + t.Errorf("Expected 4 metadata items, got %d", len(result.Metadata)) + } + + if result.Metadata["attempt"] != 3 { + t.Errorf("Expected attempt=3, got %v", result.Metadata["attempt"]) + } + + if result.Metadata["notes"] != "First submission" { + t.Errorf("Expected notes='First submission', got %v", result.Metadata["notes"]) + } +} + +func TestResultScoreRange(t *testing.T) { + tests := []struct { + name string + score float64 + valid bool + }{ + {"Zero score", 0.0, true}, + {"Positive score", 95.5, true}, + {"Max score", 100.0, true}, + {"High score", 1000.0, true}, + {"Negative score", -10.0, true}, // Negative might be valid in some contexts + {"Decimal precision", 95.123456, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &Result{ + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: tt.score, + Status: ResultStatusCompleted, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if result.Score != tt.score { + t.Errorf("Expected score %v, got %v", tt.score, result.Score) + } + }) + } +} + +func TestResultRankValidation(t *testing.T) { + tests := []struct { + name string + rank *int + }{ + {"First place", intPtr(1)}, + {"Middle rank", intPtr(50)}, + {"Last place", intPtr(1000)}, + {"No rank", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &Result{ + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + Rank: tt.rank, + Status: ResultStatusCompleted, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if tt.rank != nil && result.Rank != nil && *result.Rank != *tt.rank { + t.Errorf("Expected rank %d, got %d", *tt.rank, *result.Rank) + } + + if tt.rank == nil && result.Rank != nil { + t.Errorf("Expected rank to be nil, got %d", *result.Rank) + } + }) + } +} + +func TestResultStatusTransitions(t *testing.T) { + result := &Result{ + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 0.0, + Status: ResultStatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + result.Status = ResultStatusProcessing + if result.Status != ResultStatusProcessing { + t.Errorf("Expected status %v, got %v", ResultStatusProcessing, result.Status) + } + + result.Status = ResultStatusCompleted + result.Score = 95.5 + if result.Status != ResultStatusCompleted { + t.Errorf("Expected status %v, got %v", ResultStatusCompleted, result.Status) + } + + result.Status = ResultStatusFailed + if result.Status != ResultStatusFailed { + t.Errorf("Expected status %v, got %v", ResultStatusFailed, result.Status) + } +} + +func TestResultTimestamps(t *testing.T) { + now := time.Now() + result := &Result{ + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + Status: ResultStatusCompleted, + CreatedAt: now, + UpdatedAt: now, + } + + if !result.CreatedAt.Equal(now) { + t.Errorf("Expected CreatedAt %v, got %v", now, result.CreatedAt) + } + + if !result.UpdatedAt.Equal(now) { + t.Errorf("Expected UpdatedAt %v, got %v", now, result.UpdatedAt) + } + + if result.UpdatedAt.Before(result.CreatedAt) { + t.Errorf("UpdatedAt should not be before CreatedAt") + } +} + +func TestResultWithSubmission(t *testing.T) { + submissionID := uuid.New() + result := &Result{ + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + Status: ResultStatusCompleted, + SubmissionID: &submissionID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if result.SubmissionID == nil { + t.Error("Expected SubmissionID to be set") + } + + if *result.SubmissionID != submissionID { + t.Errorf("Expected SubmissionID %v, got %v", submissionID, *result.SubmissionID) + } +} + +func TestResultComparison(t *testing.T) { + competitionID := uuid.New() + now := time.Now() + + result1 := &Result{ + ID: uuid.New(), + CompetitionID: competitionID, + UserID: uuid.New(), + Score: 100.0, + Rank: intPtr(1), + Status: ResultStatusCompleted, + CreatedAt: now, + UpdatedAt: now, + } + + result2 := &Result{ + ID: uuid.New(), + CompetitionID: competitionID, + UserID: uuid.New(), + Score: 95.0, + Rank: intPtr(2), + Status: ResultStatusCompleted, + CreatedAt: now.Add(1 * time.Second), + UpdatedAt: now.Add(1 * time.Second), + } + + if result1.Score <= result2.Score { + t.Errorf("result1 should have higher score than result2") + } + + if *result1.Rank >= *result2.Rank { + t.Errorf("result1 should have lower (better) rank than result2") + } + + if result1.CreatedAt.After(result2.CreatedAt) { + t.Errorf("result1 should be created before result2") + } +} + +// Helper function +func intPtr(i int) *int { + return &i +} diff --git a/internal/results/handler/grpc/results.go b/internal/results/handler/grpc/results.go new file mode 100644 index 0000000..b58e456 --- /dev/null +++ b/internal/results/handler/grpc/results.go @@ -0,0 +1,309 @@ +package grpc + +import ( + "context" + "log" + "sync" + + "datarush/internal/results/domain" + pb "datarush/pkg/api/results" + submissionPb "datarush/pkg/api/submission" + taskPb "datarush/pkg/api/task" + userPb "datarush/pkg/api/user" + + "github.com/google/uuid" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +type IResultService interface { + CreateResult(ctx context.Context, result *domain.Result) (*domain.Result, error) + GetResult(ctx context.Context, id uuid.UUID) (*domain.Result, error) + UpdateResult(ctx context.Context, result *domain.Result) (*domain.Result, error) + DeleteResult(ctx context.Context, id uuid.UUID) error + ListResults(ctx context.Context, pageSize int32, pageToken int32, competitionID *uuid.UUID, userID *uuid.UUID, status *domain.ResultStatus, minScore *float64, maxScore *float64) (results []domain.Result, totalCount int32, nextPageToken int32, err error) + GetByCompetitionAndUser(ctx context.Context, competitionID uuid.UUID, userID uuid.UUID) (*domain.Result, error) + GetLeaderboard(ctx context.Context, competitionID uuid.UUID, limit int32, offset int32) ([]domain.Result, int32, error) + UpdateStatus(ctx context.Context, id uuid.UUID, status domain.ResultStatus) (*domain.Result, error) + RecalculateRanks(ctx context.Context, competitionID uuid.UUID) error +} + +type ResultsHandler struct { + pb.UnimplementedResultsServiceServer + service IResultService + userServiceAddr string + submissionServiceAddr string + taskServiceAddr string + userClient userPb.UserServiceClient + submissionClient submissionPb.SubmissionServiceClient + taskClient taskPb.TaskServiceClient + mu sync.RWMutex +} + +func NewResultsHandler(s IResultService, userServiceAddr, submissionServiceAddr, taskServiceAddr string) *ResultsHandler { + return &ResultsHandler{ + service: s, + userServiceAddr: userServiceAddr, + submissionServiceAddr: submissionServiceAddr, + taskServiceAddr: taskServiceAddr, + } +} + +func (h *ResultsHandler) getUserClient(ctx context.Context) (userPb.UserServiceClient, error) { + h.mu.RLock() + if h.userClient != nil { + client := h.userClient + h.mu.RUnlock() + return client, nil + } + h.mu.RUnlock() + + h.mu.Lock() + defer h.mu.Unlock() + + if h.userClient != nil { + return h.userClient, nil + } + + conn, err := grpc.DialContext(ctx, h.userServiceAddr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithBlock()) + if err != nil { + return nil, err + } + + h.userClient = userPb.NewUserServiceClient(conn) + return h.userClient, nil +} + +func (h *ResultsHandler) getSubmissionClient(ctx context.Context) (submissionPb.SubmissionServiceClient, error) { + h.mu.RLock() + if h.submissionClient != nil { + client := h.submissionClient + h.mu.RUnlock() + return client, nil + } + h.mu.RUnlock() + + h.mu.Lock() + defer h.mu.Unlock() + + if h.submissionClient != nil { + return h.submissionClient, nil + } + + conn, err := grpc.DialContext(ctx, h.submissionServiceAddr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithBlock()) + if err != nil { + return nil, err + } + + h.submissionClient = submissionPb.NewSubmissionServiceClient(conn) + return h.submissionClient, nil +} + +func (h *ResultsHandler) getTaskClient(ctx context.Context) (taskPb.TaskServiceClient, error) { + h.mu.RLock() + if h.taskClient != nil { + client := h.taskClient + h.mu.RUnlock() + return client, nil + } + h.mu.RUnlock() + + h.mu.Lock() + defer h.mu.Unlock() + + if h.taskClient != nil { + return h.taskClient, nil + } + + conn, err := grpc.DialContext(ctx, h.taskServiceAddr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithBlock()) + if err != nil { + return nil, err + } + + h.taskClient = taskPb.NewTaskServiceClient(conn) + return h.taskClient, nil +} + +func (h *ResultsHandler) fetchUsername(ctx context.Context, userID string) string { + client, err := h.getUserClient(ctx) + if err != nil { + log.Printf("failed to get user client: %v", err) + return "" + } + + user, err := client.GetProfile(ctx, &userPb.GetProfileRequest{UserId: userID}) + if err != nil { + log.Printf("failed to get user profile for user %s: %v", userID, err) + return "" + } + + return user.GetUsername() +} + +func (h *ResultsHandler) fetchTaskStatuses(ctx context.Context, competitionID, userID string) []*pb.TaskStatus { + client, err := h.getSubmissionClient(ctx) + if err != nil { + log.Printf("failed to get submission client: %v", err) + return []*pb.TaskStatus{} + } + + resp, err := client.ListSubmissions(ctx, &submissionPb.ListSubmissionsRequest{ + CompetitionId: competitionID, + UserId: userID, + PageSize: 1000, + PageToken: 0, + }) + if err != nil { + log.Printf("failed to list submissions for user %s in competition %s: %v", userID, competitionID, err) + return []*pb.TaskStatus{} + } + + taskMap := make(map[string]*pb.TaskStatus) + for _, submission := range resp.GetSubmissions() { + taskID := submission.GetTaskId() + + existing, exists := taskMap[taskID] + if !exists { + taskMap[taskID] = &pb.TaskStatus{ + TaskId: taskID, + TaskTitle: "", + EarnedPoints: submission.GetEarnedPoints(), + MaxPoints: 0, + Position: nil, + } + } else { + if submission.GetEarnedPoints() > existing.EarnedPoints { + existing.EarnedPoints = submission.GetEarnedPoints() + } + } + } + + taskClient, err := h.getTaskClient(ctx) + if err != nil { + log.Printf("failed to get task client: %v", err) + } else { + for taskID, status := range taskMap { + task, err := taskClient.GetTask(ctx, &taskPb.GetTaskRequest{TaskId: taskID}) + if err != nil { + log.Printf("failed to get task %s: %v", taskID, err) + continue + } + status.TaskTitle = task.GetTitle() + status.MaxPoints = task.GetMaxPoints() + } + } + + taskStatuses := make([]*pb.TaskStatus, 0, len(taskMap)) + for _, status := range taskMap { + taskStatuses = append(taskStatuses, status) + } + + return taskStatuses +} + +func (h *ResultsHandler) enrichUserResult(ctx context.Context, result *domain.Result, competitionID string) *pb.UserResult { + userID := result.UserID.String() + username := h.fetchUsername(ctx, userID) + taskStatuses := h.fetchTaskStatuses(ctx, competitionID, userID) + + pbResult := &pb.UserResult{ + UserId: userID, + Username: username, + TotalScore: int32(result.Score), + OverallPosition: 0, + TaskStatuses: taskStatuses, + } + + if result.Rank != nil { + pbResult.OverallPosition = int32(*result.Rank) + } + + return pbResult +} + +func (h *ResultsHandler) GetCompetitionResults(ctx context.Context, req *pb.GetCompetitionResultsRequest) (*pb.GetCompetitionResultsResponse, error) { + competitionID, err := uuid.Parse(req.GetCompetitionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid competition id format: %v", err) + } + + results, total, err := h.service.GetLeaderboard(ctx, competitionID, req.GetPageSize(), req.GetPageToken()) + if err != nil { + log.Printf("failed to get competition results: %v", err) + return nil, status.Errorf(codes.Internal, "failed to get competition results: %v", err) + } + + pbResults := make([]*pb.UserResult, len(results)) + var wg sync.WaitGroup + + for i := range results { + wg.Add(1) + go func(idx int) { + defer wg.Done() + pbResults[idx] = h.enrichUserResult(ctx, &results[idx], req.GetCompetitionId()) + }(i) + } + + wg.Wait() + + var nextPageToken int32 + if int(req.GetPageToken()+req.GetPageSize()) < int(total) { + nextPageToken = req.GetPageToken() + 1 + } + + return &pb.GetCompetitionResultsResponse{ + Results: pbResults, + TotalCount: total, + NextPageToken: nextPageToken, + }, nil +} + +func (h *ResultsHandler) GetUserCompetitionResults(ctx context.Context, req *pb.GetUserCompetitionResultsRequest) (*pb.GetUserCompetitionResultsResponse, error) { + competitionID, err := uuid.Parse(req.GetCompetitionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid competition id format: %v", err) + } + + userID, err := uuid.Parse(req.GetUserId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid user id format: %v", err) + } + + result, err := h.service.GetByCompetitionAndUser(ctx, competitionID, userID) + if err != nil { + if err == domain.ErrResultNotFound { + return nil, status.Errorf(codes.NotFound, "result not found for user in competition") + } + log.Printf("failed to get user competition results: %v", err) + return nil, status.Errorf(codes.Internal, "failed to get user competition results: %v", err) + } + + pbResult := h.enrichUserResult(ctx, result, req.GetCompetitionId()) + + return &pb.GetUserCompetitionResultsResponse{ + Result: pbResult, + }, nil +} + +func (h *ResultsHandler) RecalculateResults(ctx context.Context, req *pb.RecalculateResultsRequest) (*emptypb.Empty, error) { + competitionID, err := uuid.Parse(req.GetCompetitionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid competition id format: %v", err) + } + + if err := h.service.RecalculateRanks(ctx, competitionID); err != nil { + log.Printf("failed to recalculate results: %v", err) + return nil, status.Errorf(codes.Internal, "failed to recalculate results: %v", err) + } + + return &emptypb.Empty{}, nil +} diff --git a/internal/results/handler/grpc/results_test.go b/internal/results/handler/grpc/results_test.go new file mode 100644 index 0000000..667e7a6 --- /dev/null +++ b/internal/results/handler/grpc/results_test.go @@ -0,0 +1,653 @@ +package grpc + +import ( + "context" + "errors" + "testing" + + "datarush/internal/results/domain" + pb "datarush/pkg/api/results" + + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type MockResultService struct { + CreateResultFunc func(ctx context.Context, result *domain.Result) (*domain.Result, error) + GetResultFunc func(ctx context.Context, id uuid.UUID) (*domain.Result, error) + UpdateResultFunc func(ctx context.Context, result *domain.Result) (*domain.Result, error) + DeleteResultFunc func(ctx context.Context, id uuid.UUID) error + ListResultsFunc func(ctx context.Context, pageSize int32, pageToken int32, competitionID *uuid.UUID, userID *uuid.UUID, status *domain.ResultStatus, minScore *float64, maxScore *float64) (results []domain.Result, totalCount int32, nextPageToken int32, err error) + GetByCompetitionAndUserFunc func(ctx context.Context, competitionID uuid.UUID, userID uuid.UUID) (*domain.Result, error) + GetLeaderboardFunc func(ctx context.Context, competitionID uuid.UUID, limit int32, offset int32) ([]domain.Result, int32, error) + UpdateStatusFunc func(ctx context.Context, id uuid.UUID, status domain.ResultStatus) (*domain.Result, error) + RecalculateRanksFunc func(ctx context.Context, competitionID uuid.UUID) error +} + +func (m *MockResultService) CreateResult(ctx context.Context, result *domain.Result) (*domain.Result, error) { + if m.CreateResultFunc != nil { + return m.CreateResultFunc(ctx, result) + } + return nil, errors.New("not implemented") +} + +func (m *MockResultService) GetResult(ctx context.Context, id uuid.UUID) (*domain.Result, error) { + if m.GetResultFunc != nil { + return m.GetResultFunc(ctx, id) + } + return nil, errors.New("not implemented") +} + +func (m *MockResultService) UpdateResult(ctx context.Context, result *domain.Result) (*domain.Result, error) { + if m.UpdateResultFunc != nil { + return m.UpdateResultFunc(ctx, result) + } + return nil, errors.New("not implemented") +} + +func (m *MockResultService) DeleteResult(ctx context.Context, id uuid.UUID) error { + if m.DeleteResultFunc != nil { + return m.DeleteResultFunc(ctx, id) + } + return errors.New("not implemented") +} + +func (m *MockResultService) ListResults(ctx context.Context, pageSize int32, pageToken int32, competitionID *uuid.UUID, userID *uuid.UUID, status *domain.ResultStatus, minScore *float64, maxScore *float64) (results []domain.Result, totalCount int32, nextPageToken int32, err error) { + if m.ListResultsFunc != nil { + return m.ListResultsFunc(ctx, pageSize, pageToken, competitionID, userID, status, minScore, maxScore) + } + return nil, 0, 0, errors.New("not implemented") +} + +func (m *MockResultService) GetByCompetitionAndUser(ctx context.Context, competitionID uuid.UUID, userID uuid.UUID) (*domain.Result, error) { + if m.GetByCompetitionAndUserFunc != nil { + return m.GetByCompetitionAndUserFunc(ctx, competitionID, userID) + } + return nil, errors.New("not implemented") +} + +func (m *MockResultService) GetLeaderboard(ctx context.Context, competitionID uuid.UUID, limit int32, offset int32) ([]domain.Result, int32, error) { + if m.GetLeaderboardFunc != nil { + return m.GetLeaderboardFunc(ctx, competitionID, limit, offset) + } + return nil, 0, errors.New("not implemented") +} + +func (m *MockResultService) UpdateStatus(ctx context.Context, id uuid.UUID, status domain.ResultStatus) (*domain.Result, error) { + if m.UpdateStatusFunc != nil { + return m.UpdateStatusFunc(ctx, id, status) + } + return nil, errors.New("not implemented") +} + +func (m *MockResultService) RecalculateRanks(ctx context.Context, competitionID uuid.UUID) error { + if m.RecalculateRanksFunc != nil { + return m.RecalculateRanksFunc(ctx, competitionID) + } + return errors.New("not implemented") +} + +func TestNewResultsHandler(t *testing.T) { + mockService := &MockResultService{} + handler := NewResultsHandler(mockService, "localhost:50051", "localhost:50055", "localhost:50056") + + if handler == nil { + t.Fatal("Expected handler to be created, got nil") + } + + if handler.service == nil { + t.Error("Expected service to be set") + } + + if handler.userServiceAddr != "localhost:50051" { + t.Errorf("Expected userServiceAddr to be localhost:50051, got %s", handler.userServiceAddr) + } + + if handler.submissionServiceAddr != "localhost:50055" { + t.Errorf("Expected submissionServiceAddr to be localhost:50055, got %s", handler.submissionServiceAddr) + } + + if handler.taskServiceAddr != "localhost:50056" { + t.Errorf("Expected taskServiceAddr to be localhost:50056, got %s", handler.taskServiceAddr) + } +} + +func TestGetCompetitionResults_InvalidCompetitionID(t *testing.T) { + mockService := &MockResultService{} + handler := NewResultsHandler(mockService, "", "", "") + + req := &pb.GetCompetitionResultsRequest{ + CompetitionId: "invalid-uuid", + PageSize: 10, + PageToken: 0, + } + + resp, err := handler.GetCompetitionResults(context.Background(), req) + + if err == nil { + t.Fatal("Expected error for invalid competition ID, got nil") + } + + if resp != nil { + t.Error("Expected nil response for invalid competition ID") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("Expected gRPC status error") + } + + if st.Code() != codes.InvalidArgument { + t.Errorf("Expected code InvalidArgument, got %v", st.Code()) + } +} + +func TestGetCompetitionResults_ServiceError(t *testing.T) { + mockService := &MockResultService{ + GetLeaderboardFunc: func(ctx context.Context, competitionID uuid.UUID, limit int32, offset int32) ([]domain.Result, int32, error) { + return nil, 0, errors.New("database error") + }, + } + handler := NewResultsHandler(mockService, "", "", "") + + competitionID := uuid.New() + req := &pb.GetCompetitionResultsRequest{ + CompetitionId: competitionID.String(), + PageSize: 10, + PageToken: 0, + } + + resp, err := handler.GetCompetitionResults(context.Background(), req) + + if err == nil { + t.Fatal("Expected error from service, got nil") + } + + if resp != nil { + t.Error("Expected nil response on service error") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("Expected gRPC status error") + } + + if st.Code() != codes.Internal { + t.Errorf("Expected code Internal, got %v", st.Code()) + } +} + +func TestGetCompetitionResults_EmptyLeaderboard(t *testing.T) { + mockService := &MockResultService{ + GetLeaderboardFunc: func(ctx context.Context, competitionID uuid.UUID, limit int32, offset int32) ([]domain.Result, int32, error) { + return []domain.Result{}, 0, nil + }, + } + handler := NewResultsHandler(mockService, "", "", "") + + competitionID := uuid.New() + req := &pb.GetCompetitionResultsRequest{ + CompetitionId: competitionID.String(), + PageSize: 10, + PageToken: 0, + } + + resp, err := handler.GetCompetitionResults(context.Background(), req) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if resp == nil { + t.Fatal("Expected response, got nil") + } + + if len(resp.Results) != 0 { + t.Errorf("Expected 0 results, got %d", len(resp.Results)) + } + + if resp.TotalCount != 0 { + t.Errorf("Expected total count 0, got %d", resp.TotalCount) + } + + if resp.NextPageToken != 0 { + t.Errorf("Expected next page token 0, got %d", resp.NextPageToken) + } +} + +func TestGetUserCompetitionResults_InvalidCompetitionID(t *testing.T) { + mockService := &MockResultService{} + handler := NewResultsHandler(mockService, "", "", "") + + req := &pb.GetUserCompetitionResultsRequest{ + CompetitionId: "invalid-uuid", + UserId: uuid.New().String(), + } + + resp, err := handler.GetUserCompetitionResults(context.Background(), req) + + if err == nil { + t.Fatal("Expected error for invalid competition ID, got nil") + } + + if resp != nil { + t.Error("Expected nil response for invalid competition ID") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("Expected gRPC status error") + } + + if st.Code() != codes.InvalidArgument { + t.Errorf("Expected code InvalidArgument, got %v", st.Code()) + } +} + +func TestGetUserCompetitionResults_InvalidUserID(t *testing.T) { + mockService := &MockResultService{} + handler := NewResultsHandler(mockService, "", "", "") + + req := &pb.GetUserCompetitionResultsRequest{ + CompetitionId: uuid.New().String(), + UserId: "invalid-uuid", + } + + resp, err := handler.GetUserCompetitionResults(context.Background(), req) + + if err == nil { + t.Fatal("Expected error for invalid user ID, got nil") + } + + if resp != nil { + t.Error("Expected nil response for invalid user ID") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("Expected gRPC status error") + } + + if st.Code() != codes.InvalidArgument { + t.Errorf("Expected code InvalidArgument, got %v", st.Code()) + } +} + +func TestGetUserCompetitionResults_NotFound(t *testing.T) { + mockService := &MockResultService{ + GetByCompetitionAndUserFunc: func(ctx context.Context, competitionID uuid.UUID, userID uuid.UUID) (*domain.Result, error) { + return nil, domain.ErrResultNotFound + }, + } + handler := NewResultsHandler(mockService, "", "", "") + + req := &pb.GetUserCompetitionResultsRequest{ + CompetitionId: uuid.New().String(), + UserId: uuid.New().String(), + } + + resp, err := handler.GetUserCompetitionResults(context.Background(), req) + + if err == nil { + t.Fatal("Expected error for not found result, got nil") + } + + if resp != nil { + t.Error("Expected nil response for not found result") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("Expected gRPC status error") + } + + if st.Code() != codes.NotFound { + t.Errorf("Expected code NotFound, got %v", st.Code()) + } +} + +func TestGetUserCompetitionResults_Success(t *testing.T) { + rank := 1 + competitionID := uuid.New() + userID := uuid.New() + + mockService := &MockResultService{ + GetByCompetitionAndUserFunc: func(ctx context.Context, compID uuid.UUID, uID uuid.UUID) (*domain.Result, error) { + return &domain.Result{ + ID: uuid.New(), + CompetitionID: compID, + UserID: uID, + Score: 95.5, + Rank: &rank, + Status: domain.ResultStatusCompleted, + }, nil + }, + } + handler := NewResultsHandler(mockService, "", "", "") + + req := &pb.GetUserCompetitionResultsRequest{ + CompetitionId: competitionID.String(), + UserId: userID.String(), + } + + resp, err := handler.GetUserCompetitionResults(context.Background(), req) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if resp == nil { + t.Fatal("Expected response, got nil") + } + + if resp.Result == nil { + t.Fatal("Expected result, got nil") + } + + if resp.Result.UserId != userID.String() { + t.Errorf("Expected user ID %s, got %s", userID.String(), resp.Result.UserId) + } + + if resp.Result.TotalScore != 95 { + t.Errorf("Expected total score 95, got %d", resp.Result.TotalScore) + } + + if resp.Result.OverallPosition != 1 { + t.Errorf("Expected overall position 1, got %d", resp.Result.OverallPosition) + } +} + +func TestRecalculateResults_InvalidCompetitionID(t *testing.T) { + mockService := &MockResultService{} + handler := NewResultsHandler(mockService, "", "", "") + + req := &pb.RecalculateResultsRequest{ + CompetitionId: "invalid-uuid", + } + + resp, err := handler.RecalculateResults(context.Background(), req) + + if err == nil { + t.Fatal("Expected error for invalid competition ID, got nil") + } + + if resp != nil { + t.Error("Expected nil response for invalid competition ID") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("Expected gRPC status error") + } + + if st.Code() != codes.InvalidArgument { + t.Errorf("Expected code InvalidArgument, got %v", st.Code()) + } +} + +func TestRecalculateResults_ServiceError(t *testing.T) { + mockService := &MockResultService{ + RecalculateRanksFunc: func(ctx context.Context, competitionID uuid.UUID) error { + return errors.New("recalculation failed") + }, + } + handler := NewResultsHandler(mockService, "", "", "") + + req := &pb.RecalculateResultsRequest{ + CompetitionId: uuid.New().String(), + } + + resp, err := handler.RecalculateResults(context.Background(), req) + + if err == nil { + t.Fatal("Expected error from service, got nil") + } + + if resp != nil { + t.Error("Expected nil response on service error") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("Expected gRPC status error") + } + + if st.Code() != codes.Internal { + t.Errorf("Expected code Internal, got %v", st.Code()) + } +} + +func TestRecalculateResults_Success(t *testing.T) { + mockService := &MockResultService{ + RecalculateRanksFunc: func(ctx context.Context, competitionID uuid.UUID) error { + return nil + }, + } + handler := NewResultsHandler(mockService, "", "", "") + + req := &pb.RecalculateResultsRequest{ + CompetitionId: uuid.New().String(), + } + + resp, err := handler.RecalculateResults(context.Background(), req) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if resp == nil { + t.Fatal("Expected response, got nil") + } +} + +func TestFetchUsername(t *testing.T) { + handler := &ResultsHandler{ + userServiceAddr: "invalid-address", + } + + // Test with invalid address - should return empty string + username := handler.fetchUsername(context.Background(), uuid.New().String()) + + if username != "" { + t.Errorf("Expected empty username on error, got %s", username) + } +} + +func TestFetchTaskStatuses(t *testing.T) { + handler := &ResultsHandler{ + submissionServiceAddr: "invalid-address", + } + + // Test with invalid address - should return empty slice + statuses := handler.fetchTaskStatuses(context.Background(), uuid.New().String(), uuid.New().String()) + + if statuses == nil { + t.Fatal("Expected empty slice, got nil") + } + + if len(statuses) != 0 { + t.Errorf("Expected 0 statuses on error, got %d", len(statuses)) + } +} + +func TestEnrichUserResult(t *testing.T) { + rank := 1 + result := &domain.Result{ + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + Rank: &rank, + Status: domain.ResultStatusCompleted, + } + + handler := &ResultsHandler{ + userServiceAddr: "invalid-address", + submissionServiceAddr: "invalid-address", + taskServiceAddr: "invalid-address", + } + + competitionID := uuid.New().String() + pbResult := handler.enrichUserResult(context.Background(), result, competitionID) + + if pbResult == nil { + t.Fatal("Expected result, got nil") + } + + if pbResult.UserId != result.UserID.String() { + t.Errorf("Expected user ID %s, got %s", result.UserID.String(), pbResult.UserId) + } + + if pbResult.TotalScore != int32(result.Score) { + t.Errorf("Expected total score %d, got %d", int32(result.Score), pbResult.TotalScore) + } + + if pbResult.OverallPosition != int32(*result.Rank) { + t.Errorf("Expected position %d, got %d", *result.Rank, pbResult.OverallPosition) + } + + if pbResult.Username != "" { + t.Errorf("Expected empty username, got %s", pbResult.Username) + } + + if len(pbResult.TaskStatuses) != 0 { + t.Errorf("Expected 0 task statuses, got %d", len(pbResult.TaskStatuses)) + } +} + +func TestEnrichUserResult_WithoutRank(t *testing.T) { + result := &domain.Result{ + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + Rank: nil, + Status: domain.ResultStatusCompleted, + } + + handler := &ResultsHandler{ + userServiceAddr: "invalid-address", + submissionServiceAddr: "invalid-address", + taskServiceAddr: "invalid-address", + } + + competitionID := uuid.New().String() + pbResult := handler.enrichUserResult(context.Background(), result, competitionID) + + if pbResult == nil { + t.Fatal("Expected result, got nil") + } + + if pbResult.OverallPosition != 0 { + t.Errorf("Expected position 0 for nil rank, got %d", pbResult.OverallPosition) + } +} + +func TestGetCompetitionResults_Pagination(t *testing.T) { + rank1, rank2, rank3 := 1, 2, 3 + results := []domain.Result{ + { + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 100.0, + Rank: &rank1, + Status: domain.ResultStatusCompleted, + }, + { + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.0, + Rank: &rank2, + Status: domain.ResultStatusCompleted, + }, + { + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 90.0, + Rank: &rank3, + Status: domain.ResultStatusCompleted, + }, + } + + mockService := &MockResultService{ + GetLeaderboardFunc: func(ctx context.Context, competitionID uuid.UUID, limit int32, offset int32) ([]domain.Result, int32, error) { + return results, 25, nil + }, + } + handler := NewResultsHandler(mockService, "", "", "") + + competitionID := uuid.New() + req := &pb.GetCompetitionResultsRequest{ + CompetitionId: competitionID.String(), + PageSize: 10, + PageToken: 0, + } + + resp, err := handler.GetCompetitionResults(context.Background(), req) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if resp == nil { + t.Fatal("Expected response, got nil") + } + + if len(resp.Results) != 3 { + t.Errorf("Expected 3 results, got %d", len(resp.Results)) + } + + if resp.TotalCount != 25 { + t.Errorf("Expected total count 25, got %d", resp.TotalCount) + } + + if resp.NextPageToken != 1 { + t.Errorf("Expected next page token 1, got %d", resp.NextPageToken) + } +} + +func TestGetCompetitionResults_LastPage(t *testing.T) { + rank1 := 1 + results := []domain.Result{ + { + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 100.0, + Rank: &rank1, + Status: domain.ResultStatusCompleted, + }, + } + + mockService := &MockResultService{ + GetLeaderboardFunc: func(ctx context.Context, competitionID uuid.UUID, limit int32, offset int32) ([]domain.Result, int32, error) { + return results, 1, nil + }, + } + handler := NewResultsHandler(mockService, "", "", "") + + competitionID := uuid.New() + req := &pb.GetCompetitionResultsRequest{ + CompetitionId: competitionID.String(), + PageSize: 10, + PageToken: 0, + } + + resp, err := handler.GetCompetitionResults(context.Background(), req) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if resp == nil { + t.Fatal("Expected response, got nil") + } + + if resp.NextPageToken != 0 { + t.Errorf("Expected next page token 0 on last page, got %d", resp.NextPageToken) + } +} diff --git a/internal/results/repository/postgres/result.go b/internal/results/repository/postgres/result.go new file mode 100644 index 0000000..22c6a75 --- /dev/null +++ b/internal/results/repository/postgres/result.go @@ -0,0 +1,429 @@ +package postgres + +import ( + "context" + "database/sql" + "datarush/internal/results/domain" + "datarush/internal/results/repository" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/redis/go-redis/v9" +) + +const ( + resultCachePrefix = "result:" +) + +type ResultRepository struct { + db *sql.DB + redisClient *redis.Client + cacheEnabled bool +} + +func NewResultRepository(db *sql.DB, redisClient *redis.Client, cacheEnabled bool) repository.ResultRepository { + return &ResultRepository{ + db: db, + redisClient: redisClient, + cacheEnabled: cacheEnabled, + } +} + +func (r *ResultRepository) cacheKey(id string) string { + return resultCachePrefix + id +} + +func (r *ResultRepository) Create(ctx context.Context, result *domain.Result) error { + metadataJSON, err := json.Marshal(result.Metadata) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + query := `INSERT INTO results (id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)` + _, err = r.db.ExecContext(ctx, query, + result.ID, + result.CompetitionID, + result.UserID, + result.Score, + result.Rank, + result.Status, + result.SubmissionID, + metadataJSON, + result.CreatedAt, + result.UpdatedAt, + ) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { + return domain.ErrDuplicateResult + } + return err + } + + if r.cacheEnabled { + data, err := json.Marshal(result) + if err == nil { + r.redisClient.Set(ctx, r.cacheKey(result.ID.String()), data, 10*time.Minute).Err() + } + } + + return nil +} + +func (r *ResultRepository) Get(ctx context.Context, id uuid.UUID) (*domain.Result, error) { + if r.cacheEnabled { + val, err := r.redisClient.Get(ctx, r.cacheKey(id.String())).Result() + if err == nil { + var result domain.Result + if json.Unmarshal([]byte(val), &result) == nil { + return &result, nil + } + } + } + + query := `SELECT id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at + FROM results WHERE id = $1` + row := r.db.QueryRowContext(ctx, query, id) + + var result domain.Result + var metadataJSON []byte + err := row.Scan( + &result.ID, + &result.CompetitionID, + &result.UserID, + &result.Score, + &result.Rank, + &result.Status, + &result.SubmissionID, + &metadataJSON, + &result.CreatedAt, + &result.UpdatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, domain.ErrResultNotFound + } + return nil, err + } + + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &result.Metadata); err != nil { + return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) + } + } + + if r.cacheEnabled { + data, err := json.Marshal(&result) + if err == nil { + r.redisClient.Set(ctx, r.cacheKey(id.String()), data, 10*time.Minute).Err() + } + } + + return &result, nil +} + +func (r *ResultRepository) Update(ctx context.Context, result *domain.Result) error { + metadataJSON, err := json.Marshal(result.Metadata) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + query := `UPDATE results SET + competition_id = $2, user_id = $3, score = $4, rank = $5, status = $6, + submission_id = $7, metadata = $8, updated_at = $9 + WHERE id = $1` + res, err := r.db.ExecContext(ctx, query, + result.ID, + result.CompetitionID, + result.UserID, + result.Score, + result.Rank, + result.Status, + result.SubmissionID, + metadataJSON, + result.UpdatedAt, + ) + if err != nil { + return err + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return domain.ErrResultNotFound + } + + if r.cacheEnabled { + r.redisClient.Del(ctx, r.cacheKey(result.ID.String())).Err() + } + + return nil +} + +func (r *ResultRepository) Delete(ctx context.Context, id uuid.UUID) error { + query := `DELETE FROM results WHERE id = $1` + res, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return err + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return domain.ErrResultNotFound + } + + if r.cacheEnabled { + r.redisClient.Del(ctx, r.cacheKey(id.String())).Err() + } + + return nil +} + +func (r *ResultRepository) List(ctx context.Context, opts repository.ListResultsOptions) ([]domain.Result, int, error) { + var args []interface{} + var whereClauses []string + argId := 1 + + if opts.CompetitionID != nil { + whereClauses = append(whereClauses, fmt.Sprintf("competition_id = $%d", argId)) + args = append(args, *opts.CompetitionID) + argId++ + } + + if opts.UserID != nil { + whereClauses = append(whereClauses, fmt.Sprintf("user_id = $%d", argId)) + args = append(args, *opts.UserID) + argId++ + } + + if opts.Status != nil { + whereClauses = append(whereClauses, fmt.Sprintf("status = $%d", argId)) + args = append(args, *opts.Status) + argId++ + } + + if opts.MinScore != nil { + whereClauses = append(whereClauses, fmt.Sprintf("score >= $%d", argId)) + args = append(args, *opts.MinScore) + argId++ + } + + if opts.MaxScore != nil { + whereClauses = append(whereClauses, fmt.Sprintf("score <= $%d", argId)) + args = append(args, *opts.MaxScore) + argId++ + } + + where := "" + if len(whereClauses) > 0 { + where = "WHERE " + strings.Join(whereClauses, " AND ") + } + + countQuery := "SELECT COUNT(*) FROM results " + where + var total int + if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + + query := fmt.Sprintf(`SELECT id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at + FROM results %s ORDER BY score DESC, created_at ASC LIMIT $%d OFFSET $%d`, where, argId, argId+1) + args = append(args, opts.PageSize, opts.Page*opts.PageSize) + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var results []domain.Result + for rows.Next() { + var result domain.Result + var metadataJSON []byte + err := rows.Scan( + &result.ID, + &result.CompetitionID, + &result.UserID, + &result.Score, + &result.Rank, + &result.Status, + &result.SubmissionID, + &metadataJSON, + &result.CreatedAt, + &result.UpdatedAt, + ) + if err != nil { + return nil, 0, err + } + + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &result.Metadata); err != nil { + return nil, 0, fmt.Errorf("failed to unmarshal metadata: %w", err) + } + } + + results = append(results, result) + } + + return results, total, nil +} + +func (r *ResultRepository) GetByCompetitionAndUser(ctx context.Context, competitionID uuid.UUID, userID uuid.UUID) (*domain.Result, error) { + query := `SELECT id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at + FROM results WHERE competition_id = $1 AND user_id = $2` + row := r.db.QueryRowContext(ctx, query, competitionID, userID) + + var result domain.Result + var metadataJSON []byte + err := row.Scan( + &result.ID, + &result.CompetitionID, + &result.UserID, + &result.Score, + &result.Rank, + &result.Status, + &result.SubmissionID, + &metadataJSON, + &result.CreatedAt, + &result.UpdatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, domain.ErrResultNotFound + } + return nil, err + } + + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &result.Metadata); err != nil { + return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) + } + } + + return &result, nil +} + +func (r *ResultRepository) GetLeaderboard(ctx context.Context, competitionID uuid.UUID, limit int, offset int) ([]domain.Result, int, error) { + countQuery := `SELECT COUNT(*) FROM results WHERE competition_id = $1 AND status = $2` + var total int + if err := r.db.QueryRowContext(ctx, countQuery, competitionID, domain.ResultStatusCompleted).Scan(&total); err != nil { + return nil, 0, err + } + + query := `SELECT id, competition_id, user_id, score, rank, status, submission_id, metadata, created_at, updated_at + FROM results + WHERE competition_id = $1 AND status = $2 + ORDER BY score DESC, created_at ASC + LIMIT $3 OFFSET $4` + + rows, err := r.db.QueryContext(ctx, query, competitionID, domain.ResultStatusCompleted, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var results []domain.Result + for rows.Next() { + var result domain.Result + var metadataJSON []byte + err := rows.Scan( + &result.ID, + &result.CompetitionID, + &result.UserID, + &result.Score, + &result.Rank, + &result.Status, + &result.SubmissionID, + &metadataJSON, + &result.CreatedAt, + &result.UpdatedAt, + ) + if err != nil { + return nil, 0, err + } + + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &result.Metadata); err != nil { + return nil, 0, fmt.Errorf("failed to unmarshal metadata: %w", err) + } + } + + results = append(results, result) + } + + return results, total, nil +} + +func (r *ResultRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status domain.ResultStatus) (*domain.Result, error) { + query := `UPDATE results SET status = $1, updated_at = $2 WHERE id = $3` + now := time.Now() + res, err := r.db.ExecContext(ctx, query, status, now, id) + if err != nil { + return nil, err + } + + rows, err := res.RowsAffected() + if err != nil { + return nil, err + } + if rows == 0 { + return nil, domain.ErrResultNotFound + } + + if r.cacheEnabled { + r.redisClient.Del(ctx, r.cacheKey(id.String())).Err() + } + + return r.Get(ctx, id) +} + +func (r *ResultRepository) RecalculateRanks(ctx context.Context, competitionID uuid.UUID) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + query := `SELECT id FROM results + WHERE competition_id = $1 AND status = $2 + ORDER BY score DESC, created_at ASC` + + rows, err := tx.QueryContext(ctx, query, competitionID, domain.ResultStatusCompleted) + if err != nil { + return err + } + + var ids []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + rows.Close() + + updateQuery := `UPDATE results SET rank = $1, updated_at = $2 WHERE id = $3` + now := time.Now() + for i, id := range ids { + rank := i + 1 + if _, err := tx.ExecContext(ctx, updateQuery, rank, now, id); err != nil { + return err + } + + if r.cacheEnabled { + r.redisClient.Del(ctx, r.cacheKey(id.String())).Err() + } + } + + return tx.Commit() +} diff --git a/internal/results/repository/result.go b/internal/results/repository/result.go new file mode 100644 index 0000000..b6c7779 --- /dev/null +++ b/internal/results/repository/result.go @@ -0,0 +1,30 @@ +package repository + +import ( + "context" + "datarush/internal/results/domain" + + "github.com/google/uuid" +) + +type ListResultsOptions struct { + Page int + PageSize int + CompetitionID *uuid.UUID + UserID *uuid.UUID + Status *domain.ResultStatus + MinScore *float64 + MaxScore *float64 +} + +type ResultRepository interface { + Create(ctx context.Context, result *domain.Result) error + Get(ctx context.Context, id uuid.UUID) (*domain.Result, error) + Update(ctx context.Context, result *domain.Result) error + Delete(ctx context.Context, id uuid.UUID) error + List(ctx context.Context, opts ListResultsOptions) ([]domain.Result, int, error) + GetByCompetitionAndUser(ctx context.Context, competitionID uuid.UUID, userID uuid.UUID) (*domain.Result, error) + GetLeaderboard(ctx context.Context, competitionID uuid.UUID, limit int, offset int) ([]domain.Result, int, error) + UpdateStatus(ctx context.Context, id uuid.UUID, status domain.ResultStatus) (*domain.Result, error) + RecalculateRanks(ctx context.Context, competitionID uuid.UUID) error +} \ No newline at end of file diff --git a/internal/results/server/server.go b/internal/results/server/server.go new file mode 100644 index 0000000..048f3f7 --- /dev/null +++ b/internal/results/server/server.go @@ -0,0 +1,102 @@ +package server + +import ( + "fmt" + "log" + "net" + "net/http" + "time" + + "datarush/internal/results/config" + grpcHandlers "datarush/internal/results/handler/grpc" + "datarush/internal/results/repository/postgres" + "datarush/internal/results/service" + pb "datarush/pkg/api/results" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +const ( + httpReadTimeout = 10 * time.Second + httpWriteTimeout = 10 * time.Second + httpIdleTimeout = 60 * time.Second +) + +type Server struct { + grpcServer *grpc.Server + httpServer *http.Server + config *config.Config + db *sqlx.DB +} + +func New(cfg *config.Config) *Server { + return &Server{ + config: cfg, + } +} + +func (s *Server) Start() error { + db, err := sqlx.Connect("postgres", s.config.BuildPostgresConnStr()) + if err != nil { + return fmt.Errorf("failed to connect to postgres: %w", err) + } + s.db = db + + go func() { + if err := s.startGRPCServer(); err != nil { + log.Fatalf("failed to start gRPC server: %v", err) + } + }() + + log.Println("results service started") + return nil +} + +func (s *Server) startGRPCServer() error { + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.config.GRPCPort)) + if err != nil { + return fmt.Errorf("failed to listen on grpc port: %w", err) + } + + s.grpcServer = grpc.NewServer() + s.registerGRPCServices() + + log.Printf("starting gRPC server on port %d", s.config.GRPCPort) + return s.grpcServer.Serve(lis) +} + +func (s *Server) registerGRPCServices() { + resultRepo := postgres.NewResultRepository(s.db.DB, nil, false) + resultService := service.NewService(resultRepo) + resultHandler := grpcHandlers.NewResultsHandler( + resultService, + s.config.UserServiceAddr, + s.config.SubmissionServiceAddr, + s.config.TaskServiceAddr, + ) + + pb.RegisterResultsServiceServer(s.grpcServer, resultHandler) + + if s.config.GRPCEnableReflection { + reflection.Register(s.grpcServer) + } +} + +func (s *Server) Stop() { + log.Println("shutting down results server...") + + if s.grpcServer != nil { + s.grpcServer.GracefulStop() + } + + if s.db != nil { + if err := s.db.Close(); err != nil { + log.Printf("failed to close database connection: %v", err) + } + } + + log.Println("results server stopped") +} \ No newline at end of file diff --git a/internal/results/service/result.go b/internal/results/service/result.go new file mode 100644 index 0000000..b7efb00 --- /dev/null +++ b/internal/results/service/result.go @@ -0,0 +1,97 @@ +package service + +import ( + "context" + "time" + + "datarush/internal/results/domain" + "datarush/internal/results/repository" + + "github.com/google/uuid" +) + +type Service struct { + repo repository.ResultRepository +} + +func NewService(repo repository.ResultRepository) *Service { + return &Service{repo: repo} +} + +func (s *Service) CreateResult(ctx context.Context, result *domain.Result) (*domain.Result, error) { + now := time.Now() + if result.ID == uuid.Nil { + result.ID = uuid.New() + } + result.CreatedAt = now + result.UpdatedAt = now + + if result.Status == "" { + result.Status = domain.ResultStatusPending + } + + if err := s.repo.Create(ctx, result); err != nil { + return nil, err + } + return result, nil +} + +func (s *Service) GetResult(ctx context.Context, id uuid.UUID) (*domain.Result, error) { + return s.repo.Get(ctx, id) +} + +func (s *Service) UpdateResult(ctx context.Context, result *domain.Result) (*domain.Result, error) { + result.UpdatedAt = time.Now() + if err := s.repo.Update(ctx, result); err != nil { + return nil, err + } + return s.repo.Get(ctx, result.ID) +} + +func (s *Service) DeleteResult(ctx context.Context, id uuid.UUID) error { + return s.repo.Delete(ctx, id) +} + +func (s *Service) ListResults(ctx context.Context, pageSize int32, pageToken int32, competitionID *uuid.UUID, userID *uuid.UUID, status *domain.ResultStatus, minScore *float64, maxScore *float64) ([]domain.Result, int32, int32, error) { + opts := repository.ListResultsOptions{ + Page: int(pageToken), + PageSize: int(pageSize), + CompetitionID: competitionID, + UserID: userID, + Status: status, + MinScore: minScore, + MaxScore: maxScore, + } + + results, total, err := s.repo.List(ctx, opts) + if err != nil { + return nil, 0, 0, err + } + + var nextPageToken int32 + if (opts.Page+1)*opts.PageSize < total { + nextPageToken = int32(opts.Page + 1) + } + + return results, int32(total), nextPageToken, nil +} + +func (s *Service) GetByCompetitionAndUser(ctx context.Context, competitionID uuid.UUID, userID uuid.UUID) (*domain.Result, error) { + return s.repo.GetByCompetitionAndUser(ctx, competitionID, userID) +} + +func (s *Service) GetLeaderboard(ctx context.Context, competitionID uuid.UUID, limit int32, offset int32) ([]domain.Result, int32, error) { + results, total, err := s.repo.GetLeaderboard(ctx, competitionID, int(limit), int(offset)) + if err != nil { + return nil, 0, err + } + return results, int32(total), nil +} + +func (s *Service) UpdateStatus(ctx context.Context, id uuid.UUID, status domain.ResultStatus) (*domain.Result, error) { + return s.repo.UpdateStatus(ctx, id, status) +} + +func (s *Service) RecalculateRanks(ctx context.Context, competitionID uuid.UUID) error { + return s.repo.RecalculateRanks(ctx, competitionID) +} \ No newline at end of file diff --git a/internal/results/service/result_test.go b/internal/results/service/result_test.go new file mode 100644 index 0000000..d3e00df --- /dev/null +++ b/internal/results/service/result_test.go @@ -0,0 +1,815 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "datarush/internal/results/domain" + "datarush/internal/results/repository" + + "github.com/google/uuid" +) + +type MockRepository struct { + CreateFunc func(ctx context.Context, result *domain.Result) error + GetFunc func(ctx context.Context, id uuid.UUID) (*domain.Result, error) + UpdateFunc func(ctx context.Context, result *domain.Result) error + DeleteFunc func(ctx context.Context, id uuid.UUID) error + ListFunc func(ctx context.Context, opts repository.ListResultsOptions) ([]domain.Result, int, error) + GetByCompetitionAndUserFunc func(ctx context.Context, competitionID uuid.UUID, userID uuid.UUID) (*domain.Result, error) + GetLeaderboardFunc func(ctx context.Context, competitionID uuid.UUID, limit int, offset int) ([]domain.Result, int, error) + UpdateStatusFunc func(ctx context.Context, id uuid.UUID, status domain.ResultStatus) (*domain.Result, error) + RecalculateRanksFunc func(ctx context.Context, competitionID uuid.UUID) error +} + +func (m *MockRepository) Create(ctx context.Context, result *domain.Result) error { + if m.CreateFunc != nil { + return m.CreateFunc(ctx, result) + } + return nil +} + +func (m *MockRepository) Get(ctx context.Context, id uuid.UUID) (*domain.Result, error) { + if m.GetFunc != nil { + return m.GetFunc(ctx, id) + } + return nil, errors.New("not implemented") +} + +func (m *MockRepository) Update(ctx context.Context, result *domain.Result) error { + if m.UpdateFunc != nil { + return m.UpdateFunc(ctx, result) + } + return nil +} + +func (m *MockRepository) Delete(ctx context.Context, id uuid.UUID) error { + if m.DeleteFunc != nil { + return m.DeleteFunc(ctx, id) + } + return nil +} + +func (m *MockRepository) List(ctx context.Context, opts repository.ListResultsOptions) ([]domain.Result, int, error) { + if m.ListFunc != nil { + return m.ListFunc(ctx, opts) + } + return nil, 0, errors.New("not implemented") +} + +func (m *MockRepository) GetByCompetitionAndUser(ctx context.Context, competitionID uuid.UUID, userID uuid.UUID) (*domain.Result, error) { + if m.GetByCompetitionAndUserFunc != nil { + return m.GetByCompetitionAndUserFunc(ctx, competitionID, userID) + } + return nil, errors.New("not implemented") +} + +func (m *MockRepository) GetLeaderboard(ctx context.Context, competitionID uuid.UUID, limit int, offset int) ([]domain.Result, int, error) { + if m.GetLeaderboardFunc != nil { + return m.GetLeaderboardFunc(ctx, competitionID, limit, offset) + } + return nil, 0, errors.New("not implemented") +} + +func (m *MockRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status domain.ResultStatus) (*domain.Result, error) { + if m.UpdateStatusFunc != nil { + return m.UpdateStatusFunc(ctx, id, status) + } + return nil, errors.New("not implemented") +} + +func (m *MockRepository) RecalculateRanks(ctx context.Context, competitionID uuid.UUID) error { + if m.RecalculateRanksFunc != nil { + return m.RecalculateRanksFunc(ctx, competitionID) + } + return nil +} + +func TestNewService(t *testing.T) { + repo := &MockRepository{} + svc := NewService(repo) + + if svc == nil { + t.Fatal("Expected service to be created, got nil") + } + + if svc.repo == nil { + t.Error("Expected repository to be set") + } +} + +func TestCreateResult(t *testing.T) { + tests := []struct { + name string + input *domain.Result + repoError error + expectError bool + }{ + { + name: "successful creation with new ID", + input: &domain.Result{ + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + Status: domain.ResultStatusCompleted, + }, + repoError: nil, + expectError: false, + }, + { + name: "successful creation with existing ID", + input: &domain.Result{ + ID: uuid.New(), + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + Status: domain.ResultStatusCompleted, + }, + repoError: nil, + expectError: false, + }, + { + name: "repository error", + input: &domain.Result{ + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + }, + repoError: errors.New("database error"), + expectError: true, + }, + { + name: "empty status defaults to pending", + input: &domain.Result{ + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + }, + repoError: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &MockRepository{ + CreateFunc: func(ctx context.Context, result *domain.Result) error { + return tt.repoError + }, + } + svc := NewService(repo) + + result, err := svc.CreateResult(context.Background(), tt.input) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if result.ID == uuid.Nil { + t.Error("Expected ID to be set") + } + + if result.CreatedAt.IsZero() { + t.Error("Expected CreatedAt to be set") + } + + if result.UpdatedAt.IsZero() { + t.Error("Expected UpdatedAt to be set") + } + + if tt.input.Status == "" && result.Status != domain.ResultStatusPending { + t.Errorf("Expected status to default to PENDING, got %v", result.Status) + } + }) + } +} + +func TestGetResult(t *testing.T) { + resultID := uuid.New() + expectedResult := &domain.Result{ + ID: resultID, + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 95.5, + Status: domain.ResultStatusCompleted, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + tests := []struct { + name string + resultID uuid.UUID + repoResult *domain.Result + repoError error + expectError bool + }{ + { + name: "successful get", + resultID: resultID, + repoResult: expectedResult, + repoError: nil, + expectError: false, + }, + { + name: "result not found", + resultID: uuid.New(), + repoResult: nil, + repoError: domain.ErrResultNotFound, + expectError: true, + }, + { + name: "repository error", + resultID: resultID, + repoResult: nil, + repoError: errors.New("database error"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &MockRepository{ + GetFunc: func(ctx context.Context, id uuid.UUID) (*domain.Result, error) { + if id != tt.resultID { + return nil, domain.ErrResultNotFound + } + return tt.repoResult, tt.repoError + }, + } + svc := NewService(repo) + + result, err := svc.GetResult(context.Background(), tt.resultID) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if result.ID != expectedResult.ID { + t.Errorf("Expected ID %v, got %v", expectedResult.ID, result.ID) + } + }) + } +} + +func TestUpdateResult(t *testing.T) { + resultID := uuid.New() + originalTime := time.Now().Add(-1 * time.Hour) + + tests := []struct { + name string + input *domain.Result + repoError error + expectError bool + }{ + { + name: "successful update", + input: &domain.Result{ + ID: resultID, + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 100.0, + Status: domain.ResultStatusCompleted, + CreatedAt: originalTime, + UpdatedAt: originalTime, + }, + repoError: nil, + expectError: false, + }, + { + name: "repository error", + input: &domain.Result{ + ID: resultID, + CompetitionID: uuid.New(), + UserID: uuid.New(), + Score: 100.0, + }, + repoError: errors.New("update failed"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &MockRepository{ + UpdateFunc: func(ctx context.Context, result *domain.Result) error { + return tt.repoError + }, + GetFunc: func(ctx context.Context, id uuid.UUID) (*domain.Result, error) { + if tt.repoError != nil { + return nil, tt.repoError + } + return tt.input, nil + }, + } + svc := NewService(repo) + + beforeUpdate := time.Now() + result, err := svc.UpdateResult(context.Background(), tt.input) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if !result.UpdatedAt.After(originalTime) { + t.Error("Expected UpdatedAt to be updated") + } + + if result.UpdatedAt.Before(beforeUpdate) { + t.Error("Expected UpdatedAt to be recent") + } + }) + } +} + +func TestDeleteResult(t *testing.T) { + resultID := uuid.New() + + tests := []struct { + name string + resultID uuid.UUID + repoError error + expectError bool + }{ + { + name: "successful delete", + resultID: resultID, + repoError: nil, + expectError: false, + }, + { + name: "result not found", + resultID: uuid.New(), + repoError: domain.ErrResultNotFound, + expectError: true, + }, + { + name: "repository error", + resultID: resultID, + repoError: errors.New("delete failed"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &MockRepository{ + DeleteFunc: func(ctx context.Context, id uuid.UUID) error { + return tt.repoError + }, + } + svc := NewService(repo) + + err := svc.DeleteResult(context.Background(), tt.resultID) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +func TestListResults(t *testing.T) { + competitionID := uuid.New() + userID := uuid.New() + status := domain.ResultStatusCompleted + minScore := 50.0 + maxScore := 100.0 + + results := []domain.Result{ + { + ID: uuid.New(), + CompetitionID: competitionID, + UserID: userID, + Score: 95.0, + Status: status, + }, + { + ID: uuid.New(), + CompetitionID: competitionID, + UserID: uuid.New(), + Score: 85.0, + Status: status, + }, + } + + tests := []struct { + name string + pageSize int32 + pageToken int32 + competitionID *uuid.UUID + userID *uuid.UUID + status *domain.ResultStatus + minScore *float64 + maxScore *float64 + repoResults []domain.Result + repoTotal int + repoError error + expectError bool + expectedTotal int32 + expectedNext int32 + }{ + { + name: "successful list with filters", + pageSize: 10, + pageToken: 0, + competitionID: &competitionID, + userID: &userID, + status: &status, + minScore: &minScore, + maxScore: &maxScore, + repoResults: results, + repoTotal: 2, + repoError: nil, + expectError: false, + expectedTotal: 2, + expectedNext: 0, + }, + { + name: "pagination with next page", + pageSize: 10, + pageToken: 0, + competitionID: &competitionID, + repoResults: results, + repoTotal: 25, + repoError: nil, + expectError: false, + expectedTotal: 25, + expectedNext: 1, + }, + { + name: "repository error", + pageSize: 10, + pageToken: 0, + repoResults: nil, + repoTotal: 0, + repoError: errors.New("database error"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &MockRepository{ + ListFunc: func(ctx context.Context, opts repository.ListResultsOptions) ([]domain.Result, int, error) { + return tt.repoResults, tt.repoTotal, tt.repoError + }, + } + svc := NewService(repo) + + results, total, nextPage, err := svc.ListResults( + context.Background(), + tt.pageSize, + tt.pageToken, + tt.competitionID, + tt.userID, + tt.status, + tt.minScore, + tt.maxScore, + ) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(results) != len(tt.repoResults) { + t.Errorf("Expected %d results, got %d", len(tt.repoResults), len(results)) + } + + if total != tt.expectedTotal { + t.Errorf("Expected total %d, got %d", tt.expectedTotal, total) + } + + if nextPage != tt.expectedNext { + t.Errorf("Expected next page token %d, got %d", tt.expectedNext, nextPage) + } + }) + } +} + +func TestGetByCompetitionAndUser(t *testing.T) { + competitionID := uuid.New() + userID := uuid.New() + expectedResult := &domain.Result{ + ID: uuid.New(), + CompetitionID: competitionID, + UserID: userID, + Score: 95.5, + Status: domain.ResultStatusCompleted, + } + + tests := []struct { + name string + competitionID uuid.UUID + userID uuid.UUID + repoResult *domain.Result + repoError error + expectError bool + }{ + { + name: "successful get", + competitionID: competitionID, + userID: userID, + repoResult: expectedResult, + repoError: nil, + expectError: false, + }, + { + name: "result not found", + competitionID: uuid.New(), + userID: uuid.New(), + repoResult: nil, + repoError: domain.ErrResultNotFound, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &MockRepository{ + GetByCompetitionAndUserFunc: func(ctx context.Context, compID uuid.UUID, uID uuid.UUID) (*domain.Result, error) { + return tt.repoResult, tt.repoError + }, + } + svc := NewService(repo) + + result, err := svc.GetByCompetitionAndUser(context.Background(), tt.competitionID, tt.userID) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if result.ID != expectedResult.ID { + t.Errorf("Expected ID %v, got %v", expectedResult.ID, result.ID) + } + }) + } +} + +func TestGetLeaderboard(t *testing.T) { + competitionID := uuid.New() + rank1, rank2, rank3 := 1, 2, 3 + + leaderboard := []domain.Result{ + { + ID: uuid.New(), + CompetitionID: competitionID, + UserID: uuid.New(), + Score: 100.0, + Rank: &rank1, + Status: domain.ResultStatusCompleted, + }, + { + ID: uuid.New(), + CompetitionID: competitionID, + UserID: uuid.New(), + Score: 95.0, + Rank: &rank2, + Status: domain.ResultStatusCompleted, + }, + { + ID: uuid.New(), + CompetitionID: competitionID, + UserID: uuid.New(), + Score: 90.0, + Rank: &rank3, + Status: domain.ResultStatusCompleted, + }, + } + + tests := []struct { + name string + competitionID uuid.UUID + limit int32 + offset int32 + repoResults []domain.Result + repoTotal int + repoError error + expectError bool + }{ + { + name: "successful leaderboard", + competitionID: competitionID, + limit: 10, + offset: 0, + repoResults: leaderboard, + repoTotal: 3, + repoError: nil, + expectError: false, + }, + { + name: "empty leaderboard", + competitionID: uuid.New(), + limit: 10, + offset: 0, + repoResults: []domain.Result{}, + repoTotal: 0, + repoError: nil, + expectError: false, + }, + { + name: "repository error", + competitionID: competitionID, + limit: 10, + offset: 0, + repoResults: nil, + repoTotal: 0, + repoError: errors.New("database error"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &MockRepository{ + GetLeaderboardFunc: func(ctx context.Context, compID uuid.UUID, limit int, offset int) ([]domain.Result, int, error) { + return tt.repoResults, tt.repoTotal, tt.repoError + }, + } + svc := NewService(repo) + + results, total, err := svc.GetLeaderboard(context.Background(), tt.competitionID, tt.limit, tt.offset) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(results) != len(tt.repoResults) { + t.Errorf("Expected %d results, got %d", len(tt.repoResults), len(results)) + } + + if int32(total) != int32(tt.repoTotal) { + t.Errorf("Expected total %d, got %d", tt.repoTotal, total) + } + + for i := 0; i < len(results)-1; i++ { + if results[i].Rank != nil && results[i+1].Rank != nil { + if *results[i].Rank > *results[i+1].Rank { + t.Error("Leaderboard should be sorted by rank ascending") + } + } + } + }) + } +} + +func TestUpdateStatus(t *testing.T) { + resultID := uuid.New() + updatedResult := &domain.Result{ + ID: resultID, + Status: domain.ResultStatusCompleted, + UpdatedAt: time.Now(), + } + + tests := []struct { + name string + resultID uuid.UUID + status domain.ResultStatus + repoResult *domain.Result + repoError error + expectError bool + }{ + { + name: "successful status update", + resultID: resultID, + status: domain.ResultStatusCompleted, + repoResult: updatedResult, + repoError: nil, + expectError: false, + }, + { + name: "result not found", + resultID: uuid.New(), + status: domain.ResultStatusCompleted, + repoResult: nil, + repoError: domain.ErrResultNotFound, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &MockRepository{ + UpdateStatusFunc: func(ctx context.Context, id uuid.UUID, status domain.ResultStatus) (*domain.Result, error) { + return tt.repoResult, tt.repoError + }, + } + svc := NewService(repo) + + result, err := svc.UpdateStatus(context.Background(), tt.resultID, tt.status) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if result.Status != tt.status { + t.Errorf("Expected status %v, got %v", tt.status, result.Status) + } + }) + } +} + +func TestRecalculateRanks(t *testing.T) { + competitionID := uuid.New() + + tests := []struct { + name string + competitionID uuid.UUID + repoError error + expectError bool + }{ + { + name: "successful recalculation", + competitionID: competitionID, + repoError: nil, + expectError: false, + }, + { + name: "repository error", + competitionID: competitionID, + repoError: errors.New("recalculation failed"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &MockRepository{ + RecalculateRanksFunc: func(ctx context.Context, compID uuid.UUID) error { + return tt.repoError + }, + } + svc := NewService(repo) + + err := svc.RecalculateRanks(context.Background(), tt.competitionID) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} From f5f6c7850cf3c0dd7959efb9af41423b3ecc66a3 Mon Sep 17 00:00:00 2001 From: timka Date: Wed, 17 Dec 2025 18:38:54 +0300 Subject: [PATCH 2/2] add redis cache to results --- go.sum | 5 -- internal/results/config/config.go | 18 +++-- .../results/repository/postgres/result.go | 66 ++++++++++++++++++- internal/results/server/server.go | 17 ++++- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/go.sum b/go.sum index b25dc5c..3003467 100644 --- a/go.sum +++ b/go.sum @@ -138,13 +138,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -<<<<<<< HEAD github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= -======= -github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= -github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= ->>>>>>> origin/feature/task github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/results/config/config.go b/internal/results/config/config.go index 7144dea..eedf3ba 100644 --- a/internal/results/config/config.go +++ b/internal/results/config/config.go @@ -11,18 +11,20 @@ import ( ) type Config struct { - GRPCPort int - GRPCEnableReflection bool - HTTPPort int - LogLevel string - DBHost string - DBPort int + GRPCPort int + GRPCEnableReflection bool + HTTPPort int + LogLevel string + DBHost string + DBPort int DBUser string DBPassword string DBName string + RedisAddr string UserServiceAddr string SubmissionServiceAddr string TaskServiceAddr string + CacheEnabled bool } func Load() (*Config, error) { @@ -38,9 +40,11 @@ func Load() (*Config, error) { DBUser: getEnv("POSTGRES_USERNAME", "postgres"), DBPassword: getEnv("POSTGRES_PASSWORD", "postgres"), DBName: getEnv("POSTGRES_DATABASE", "postgres"), + RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"), UserServiceAddr: getEnv("USER_GRPC_ADDR", "localhost:50051"), SubmissionServiceAddr: getEnv("SUBMISSION_GRPC_ADDR", "localhost:50055"), TaskServiceAddr: getEnv("TASK_GRPC_ADDR", "localhost:50056"), + CacheEnabled: mustGetBool("CACHE_ENABLED", false), }, nil } @@ -77,4 +81,4 @@ func (c Config) BuildPostgresConnStr() string { func (c Config) BuildPostgresDSN() string { return fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", c.DBUser, c.DBPassword, net.JoinHostPort(c.DBHost, strconv.Itoa(c.DBPort)), c.DBName) -} \ No newline at end of file +} diff --git a/internal/results/repository/postgres/result.go b/internal/results/repository/postgres/result.go index 22c6a75..a3f7582 100644 --- a/internal/results/repository/postgres/result.go +++ b/internal/results/repository/postgres/result.go @@ -16,9 +16,15 @@ import ( ) const ( - resultCachePrefix = "result:" + resultCachePrefix = "result:" + leaderboardCachePrefix = "leaderboard:" ) +type leaderboardCache struct { + Results []domain.Result `json:"results"` + TotalCount int `json:"total_count"` +} + type ResultRepository struct { db *sql.DB redisClient *redis.Client @@ -37,6 +43,21 @@ func (r *ResultRepository) cacheKey(id string) string { return resultCachePrefix + id } +func (r *ResultRepository) leaderboardCacheKey(competitionID uuid.UUID, limit, offset int) string { + return fmt.Sprintf("%s%s:limit:%d:offset:%d", leaderboardCachePrefix, competitionID.String(), limit, offset) +} + +func (r *ResultRepository) invalidateLeaderboardCache(ctx context.Context, competitionID uuid.UUID) { + if !r.cacheEnabled { + return + } + pattern := fmt.Sprintf("%s%s:*", leaderboardCachePrefix, competitionID.String()) + keys, err := r.redisClient.Keys(ctx, pattern).Result() + if err == nil && len(keys) > 0 { + r.redisClient.Del(ctx, keys...).Err() + } +} + func (r *ResultRepository) Create(ctx context.Context, result *domain.Result) error { metadataJSON, err := json.Marshal(result.Metadata) if err != nil { @@ -65,6 +86,7 @@ func (r *ResultRepository) Create(ctx context.Context, result *domain.Result) er } if r.cacheEnabled { + r.invalidateLeaderboardCache(ctx, result.CompetitionID) data, err := json.Marshal(result) if err == nil { r.redisClient.Set(ctx, r.cacheKey(result.ID.String()), data, 10*time.Minute).Err() @@ -160,6 +182,7 @@ func (r *ResultRepository) Update(ctx context.Context, result *domain.Result) er } if r.cacheEnabled { + r.invalidateLeaderboardCache(ctx, result.CompetitionID) r.redisClient.Del(ctx, r.cacheKey(result.ID.String())).Err() } @@ -312,6 +335,16 @@ func (r *ResultRepository) GetByCompetitionAndUser(ctx context.Context, competit } func (r *ResultRepository) GetLeaderboard(ctx context.Context, competitionID uuid.UUID, limit int, offset int) ([]domain.Result, int, error) { + if r.cacheEnabled { + key := r.leaderboardCacheKey(competitionID, limit, offset) + val, err := r.redisClient.Get(ctx, key).Result() + if err == nil { + var cached leaderboardCache + if json.Unmarshal([]byte(val), &cached) == nil { + return cached.Results, cached.TotalCount, nil + } + } + } countQuery := `SELECT COUNT(*) FROM results WHERE competition_id = $1 AND status = $2` var total int if err := r.db.QueryRowContext(ctx, countQuery, competitionID, domain.ResultStatusCompleted).Scan(&total); err != nil { @@ -359,6 +392,15 @@ func (r *ResultRepository) GetLeaderboard(ctx context.Context, competitionID uui results = append(results, result) } + if r.cacheEnabled { + key := r.leaderboardCacheKey(competitionID, limit, offset) + cacheData := leaderboardCache{Results: results, TotalCount: total} + data, err := json.Marshal(cacheData) + if err == nil { + r.redisClient.Set(ctx, key, data, 1*time.Minute).Err() + } + } + return results, total, nil } @@ -382,7 +424,16 @@ func (r *ResultRepository) UpdateStatus(ctx context.Context, id uuid.UUID, statu r.redisClient.Del(ctx, r.cacheKey(id.String())).Err() } - return r.Get(ctx, id) + result, err := r.Get(ctx, id) + if err != nil { + return nil, err + } + + if r.cacheEnabled { + r.invalidateLeaderboardCache(ctx, result.CompetitionID) + } + + return result, nil } func (r *ResultRepository) RecalculateRanks(ctx context.Context, competitionID uuid.UUID) error { @@ -425,5 +476,14 @@ func (r *ResultRepository) RecalculateRanks(ctx context.Context, competitionID u } } - return tx.Commit() + err = tx.Commit() + if err != nil { + return err + } + + if r.cacheEnabled { + r.invalidateLeaderboardCache(ctx, competitionID) + } + + return nil } diff --git a/internal/results/server/server.go b/internal/results/server/server.go index 048f3f7..f461d50 100644 --- a/internal/results/server/server.go +++ b/internal/results/server/server.go @@ -15,6 +15,7 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/lib/pq" + "github.com/redis/go-redis/v9" "google.golang.org/grpc" "google.golang.org/grpc/reflection" ) @@ -30,6 +31,7 @@ type Server struct { httpServer *http.Server config *config.Config db *sqlx.DB + redis *redis.Client } func New(cfg *config.Config) *Server { @@ -45,6 +47,11 @@ func (s *Server) Start() error { } s.db = db + redisClient := redis.NewClient(&redis.Options{ + Addr: s.config.RedisAddr, + }) + s.redis = redisClient + go func() { if err := s.startGRPCServer(); err != nil { log.Fatalf("failed to start gRPC server: %v", err) @@ -69,7 +76,7 @@ func (s *Server) startGRPCServer() error { } func (s *Server) registerGRPCServices() { - resultRepo := postgres.NewResultRepository(s.db.DB, nil, false) + resultRepo := postgres.NewResultRepository(s.db.DB, s.redis, s.config.CacheEnabled) resultService := service.NewService(resultRepo) resultHandler := grpcHandlers.NewResultsHandler( resultService, @@ -98,5 +105,11 @@ func (s *Server) Stop() { } } + if s.redis != nil { + if err := s.redis.Close(); err != nil { + log.Printf("failed to close redis connection: %v", err) + } + } + log.Println("results server stopped") -} \ No newline at end of file +}