add results service
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user