From 764c2272eca1def40f631fde7ac8f3f2342df400 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 14 Oct 2025 01:26:44 +0800 Subject: [PATCH 1/7] feat: implement ranking --- cmd/backend/dependencies.go | 9 +- cmd/backend/server.go | 1 + graph/model/models_gen.go | 189 +++++ graph/rank.graphqls | 39 ++ graph/rank.resolvers.go | 17 + graph/rank_resolver_test.go | 1101 ++++++++++++++++++++++++++++++ graph/resolver.go | 9 +- graph/user.resolvers_test.go | 4 +- internal/ranking/README.md | 3 + internal/ranking/ranking.go | 230 +++++++ internal/ranking/ranking_test.go | 548 +++++++++++++++ models/ranking.go | 7 + 12 files changed, 2152 insertions(+), 5 deletions(-) create mode 100644 graph/rank.graphqls create mode 100644 graph/rank.resolvers.go create mode 100644 graph/rank_resolver_test.go create mode 100644 internal/ranking/README.md create mode 100644 internal/ranking/ranking.go create mode 100644 internal/ranking/ranking_test.go create mode 100644 models/ranking.go diff --git a/cmd/backend/dependencies.go b/cmd/backend/dependencies.go index bbcc554..3073795 100644 --- a/cmd/backend/dependencies.go +++ b/cmd/backend/dependencies.go @@ -24,6 +24,7 @@ import ( "github.com/database-playground/backend-v2/internal/events" "github.com/database-playground/backend-v2/internal/graphql/apq" "github.com/database-playground/backend-v2/internal/httputils" + "github.com/database-playground/backend-v2/internal/ranking" "github.com/database-playground/backend-v2/internal/sqlrunner" "github.com/database-playground/backend-v2/internal/submission" "github.com/database-playground/backend-v2/internal/useraccount" @@ -84,9 +85,10 @@ func GqlgenHandler( useraccount *useraccount.Context, eventService *events.EventService, submissionService *submission.SubmissionService, + rankingService *ranking.Service, apqCache graphql.Cache[string], ) *handler.Server { - srv := handler.New(graph.NewSchema(entClient, storage, sqlrunner, useraccount, eventService, submissionService)) + srv := handler.New(graph.NewSchema(entClient, storage, sqlrunner, useraccount, eventService, submissionService, rankingService)) srv.AddTransport(transport.Options{}) srv.AddTransport(transport.GET{}) @@ -120,6 +122,11 @@ func SubmissionService(entClient *ent.Client, eventService *events.EventService, return submission.NewSubmissionService(entClient, eventService, sqlrunner) } +// RankingService creates a ranking.Service. +func RankingService(entClient *ent.Client) *ranking.Service { + return ranking.NewService(entClient) +} + // AuthService creates an auth service. func AuthService(entClient *ent.Client, storage auth.Storage, config config.Config, useraccount *useraccount.Context) httpapi.Service { return authservice.NewAuthService(entClient, storage, config, useraccount) diff --git a/cmd/backend/server.go b/cmd/backend/server.go index 63531a8..5006259 100644 --- a/cmd/backend/server.go +++ b/cmd/backend/server.go @@ -20,6 +20,7 @@ func main() { EventService, SubmissionService, ApqCache, + RankingService, PostHogClient, AnnotateService(AuthService), GqlgenHandler, diff --git a/graph/model/models_gen.go b/graph/model/models_gen.go index 5d35886..eacaf14 100644 --- a/graph/model/models_gen.go +++ b/graph/model/models_gen.go @@ -3,6 +3,13 @@ package model import ( + "bytes" + "fmt" + "io" + "strconv" + + "entgo.io/contrib/entgql" + "github.com/database-playground/backend-v2/ent" "github.com/database-playground/backend-v2/ent/question" "github.com/database-playground/backend-v2/models" ) @@ -16,6 +23,23 @@ type DatabaseTable struct { Columns []string `json:"columns"` } +type RankingConnection struct { + Edges []*RankingEdge `json:"edges"` + PageInfo *entgql.PageInfo[int] `json:"pageInfo"` + TotalCount int `json:"totalCount"` +} + +type RankingEdge struct { + Node *ent.User `json:"node"` + Cursor entgql.Cursor[int] `json:"cursor"` +} + +type RankingFilter struct { + By RankingBy `json:"by"` + Order RankingOrder `json:"order"` + Period RankingPeriod `json:"period"` +} + // Filter for scope sets. // // The filters are mutually exclusive, only one of them can be provided. @@ -42,3 +66,168 @@ type SubmissionStatistics struct { SolvedQuestions int `json:"solvedQuestions"` SolvedQuestionByDifficulty []*SolvedQuestionByDifficulty `json:"solvedQuestionByDifficulty"` } + +type RankingBy string + +const ( + RankingByPoints RankingBy = "POINTS" + RankingByCompletedQuestions RankingBy = "COMPLETED_QUESTIONS" +) + +var AllRankingBy = []RankingBy{ + RankingByPoints, + RankingByCompletedQuestions, +} + +func (e RankingBy) IsValid() bool { + switch e { + case RankingByPoints, RankingByCompletedQuestions: + return true + } + return false +} + +func (e RankingBy) String() string { + return string(e) +} + +func (e *RankingBy) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = RankingBy(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid RankingBy", str) + } + return nil +} + +func (e RankingBy) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *RankingBy) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e RankingBy) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type RankingOrder string + +const ( + RankingOrderAsc RankingOrder = "ASC" + RankingOrderDesc RankingOrder = "DESC" +) + +var AllRankingOrder = []RankingOrder{ + RankingOrderAsc, + RankingOrderDesc, +} + +func (e RankingOrder) IsValid() bool { + switch e { + case RankingOrderAsc, RankingOrderDesc: + return true + } + return false +} + +func (e RankingOrder) String() string { + return string(e) +} + +func (e *RankingOrder) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = RankingOrder(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid RankingOrder", str) + } + return nil +} + +func (e RankingOrder) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *RankingOrder) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e RankingOrder) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type RankingPeriod string + +const ( + RankingPeriodDaily RankingPeriod = "DAILY" + RankingPeriodWeekly RankingPeriod = "WEEKLY" +) + +var AllRankingPeriod = []RankingPeriod{ + RankingPeriodDaily, + RankingPeriodWeekly, +} + +func (e RankingPeriod) IsValid() bool { + switch e { + case RankingPeriodDaily, RankingPeriodWeekly: + return true + } + return false +} + +func (e RankingPeriod) String() string { + return string(e) +} + +func (e *RankingPeriod) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = RankingPeriod(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid RankingPeriod", str) + } + return nil +} + +func (e RankingPeriod) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *RankingPeriod) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e RankingPeriod) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} diff --git a/graph/rank.graphqls b/graph/rank.graphqls new file mode 100644 index 0000000..91a937a --- /dev/null +++ b/graph/rank.graphqls @@ -0,0 +1,39 @@ +extend type Query { + """ + Get the ranking. + """ + ranking(first: Int, after: Cursor, filter: RankingFilter!): RankingConnection! @scope(scope: "user:read") +} + +input RankingFilter { + by: RankingBy! + order: RankingOrder! + period: RankingPeriod! +} + +enum RankingBy { + POINTS + COMPLETED_QUESTIONS +} + +enum RankingOrder { + ASC + DESC +} + +enum RankingPeriod { + DAILY + WEEKLY +} + +type RankingConnection { + edges: [RankingEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type RankingEdge { + # User in the ranking + node: User! + cursor: Cursor! +} diff --git a/graph/rank.resolvers.go b/graph/rank.resolvers.go new file mode 100644 index 0000000..0315d8f --- /dev/null +++ b/graph/rank.resolvers.go @@ -0,0 +1,17 @@ +package graph + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.81 + +import ( + "context" + + "entgo.io/contrib/entgql" + "github.com/database-playground/backend-v2/graph/model" +) + +// Ranking is the resolver for the ranking field. +func (r *queryResolver) Ranking(ctx context.Context, first *int, after *entgql.Cursor[int], filter model.RankingFilter) (*model.RankingConnection, error) { + return r.rankingService.GetRanking(ctx, first, after, filter) +} diff --git a/graph/rank_resolver_test.go b/graph/rank_resolver_test.go new file mode 100644 index 0000000..9093f88 --- /dev/null +++ b/graph/rank_resolver_test.go @@ -0,0 +1,1101 @@ +package graph + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/99designs/gqlgen/client" + "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/transport" + "github.com/database-playground/backend-v2/ent" + entQuestion "github.com/database-playground/backend-v2/ent/question" + entSubmission "github.com/database-playground/backend-v2/ent/submission" + "github.com/database-playground/backend-v2/graph/defs" + "github.com/database-playground/backend-v2/graph/directive" + "github.com/database-playground/backend-v2/internal/auth" + "github.com/database-playground/backend-v2/internal/testhelper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + _ "github.com/mattn/go-sqlite3" +) + +// setupTestRankingData creates test users, points, and submissions for ranking tests +func setupTestRankingData(t *testing.T, entClient *ent.Client) ([]*ent.User, *ent.Database, []*ent.Question) { + t.Helper() + + ctx := context.Background() + + // Create test group + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + // Create test database + database, err := entClient.Database.Create(). + SetSlug("test_db"). + SetSchema(`{"tables": []}`). + SetRelationFigure("test_figure"). + Save(ctx) + require.NoError(t, err) + + // Create test questions + questions := make([]*ent.Question, 3) + for i := 0; i < 3; i++ { + q, err := entClient.Question.Create(). + SetCategory("test"). + SetTitle("Question " + strconv.Itoa(i+1)). + SetDescription("Test question"). + SetReferenceAnswer("SELECT 1"). + SetDifficulty(entQuestion.DifficultyEasy). + SetDatabase(database). + Save(ctx) + require.NoError(t, err) + questions[i] = q + } + + // Create test users + users := make([]*ent.User, 5) + for i := 0; i < 5; i++ { + user, err := entClient.User.Create(). + SetName("User " + strconv.Itoa(i+1)). + SetEmail("user" + strconv.Itoa(i+1) + "@example.com"). + SetGroup(group). + Save(ctx) + require.NoError(t, err) + users[i] = user + } + + return users, database, questions +} + +func TestQueryResolver_Ranking_ByPoints_Daily(t *testing.T) { + t.Run("descending order", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for users (today) + // User 0: 150 points, User 1: 200 points, User 2: 100 points, User 3: 50 points, User 4: 0 points + pointsData := []struct { + userIdx int + points int + }{ + {0, 150}, + {1, 200}, + {2, 100}, + {3, 50}, + } + + for _, data := range pointsData { + _, err := entClient.Point.Create(). + SetUser(users[data.userIdx]). + SetPoints(data.points). + SetGrantedAt(today.Add(time.Hour)). + SetDescription("Daily points"). + Save(ctx) + require.NoError(t, err) + } + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query + query := `query { + ranking( + first: 10, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + id + name + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + } + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + ID string + Name string + } + } + TotalCount int + PageInfo struct { + HasNextPage bool + HasPreviousPage bool + } + } + } + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 4, resp.Ranking.TotalCount) + assert.Equal(t, 4, len(resp.Ranking.Edges)) + assert.False(t, resp.Ranking.PageInfo.HasNextPage) + assert.False(t, resp.Ranking.PageInfo.HasPreviousPage) + + // Check order: User 1 (200), User 0 (150), User 2 (100), User 3 (50) + assert.Equal(t, "User 2", resp.Ranking.Edges[0].Node.Name) + assert.Equal(t, "User 1", resp.Ranking.Edges[1].Node.Name) + assert.Equal(t, "User 3", resp.Ranking.Edges[2].Node.Name) + assert.Equal(t, "User 4", resp.Ranking.Edges[3].Node.Name) + }) + + t.Run("ascending order", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for users (today) + pointsData := []struct { + userIdx int + points int + }{ + {0, 150}, + {1, 200}, + {2, 100}, + } + + for _, data := range pointsData { + _, err := entClient.Point.Create(). + SetUser(users[data.userIdx]). + SetPoints(data.points). + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query + query := `query { + ranking( + first: 10, + filter: { by: POINTS, order: ASC, period: DAILY } + ) { + edges { + node { + name + } + } + totalCount + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + } + } + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 3, resp.Ranking.TotalCount) + + // Check order: User 2 (100), User 0 (150), User 1 (200) + assert.Equal(t, "User 3", resp.Ranking.Edges[0].Node.Name) + assert.Equal(t, "User 1", resp.Ranking.Edges[1].Node.Name) + assert.Equal(t, "User 2", resp.Ranking.Edges[2].Node.Name) + }) + + t.Run("filters out yesterday's points", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + yesterday := today.Add(-24 * time.Hour) + + // Create points for yesterday (should not be included) + _, err := entClient.Point.Create(). + SetUser(users[0]). + SetPoints(500). + SetGrantedAt(yesterday.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // Create points for today + _, err = entClient.Point.Create(). + SetUser(users[1]). + SetPoints(100). + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query + query := `query { + ranking( + first: 10, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + totalCount + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + } + } + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify response - only user 1 should appear + require.NoError(t, err) + assert.Equal(t, 1, resp.Ranking.TotalCount) + assert.Equal(t, "User 2", resp.Ranking.Edges[0].Node.Name) + }) +} + +func TestQueryResolver_Ranking_ByPoints_Weekly(t *testing.T) { + t.Run("includes entire week", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + + // Calculate start of week (Monday) + weekday := now.Weekday() + daysToMonday := int(weekday - time.Monday) + if daysToMonday < 0 { + daysToMonday += 7 + } + startOfWeek := now.AddDate(0, 0, -daysToMonday) + startOfWeek = time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location()) + + // Create points throughout the week + _, err := entClient.Point.Create(). + SetUser(users[0]). + SetPoints(100). + SetGrantedAt(startOfWeek.Add(time.Hour)). // Monday + Save(ctx) + require.NoError(t, err) + + _, err = entClient.Point.Create(). + SetUser(users[0]). + SetPoints(50). + SetGrantedAt(startOfWeek.Add(3 * 24 * time.Hour)). // Thursday + Save(ctx) + require.NoError(t, err) + + _, err = entClient.Point.Create(). + SetUser(users[1]). + SetPoints(200). + SetGrantedAt(startOfWeek.Add(5 * 24 * time.Hour)). // Saturday + Save(ctx) + require.NoError(t, err) + + // Create points from last week (should not be included) + _, err = entClient.Point.Create(). + SetUser(users[2]). + SetPoints(1000). + SetGrantedAt(startOfWeek.Add(-7 * 24 * time.Hour)). + Save(ctx) + require.NoError(t, err) + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query + query := `query { + ranking( + first: 10, + filter: { by: POINTS, order: DESC, period: WEEKLY } + ) { + edges { + node { + name + } + } + totalCount + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + } + } + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 2, resp.Ranking.TotalCount) + + // User 1 (200), User 0 (150 = 100 + 50) + assert.Equal(t, "User 2", resp.Ranking.Edges[0].Node.Name) + assert.Equal(t, "User 1", resp.Ranking.Edges[1].Node.Name) + }) +} + +func TestQueryResolver_Ranking_ByCompletedQuestions(t *testing.T) { + t.Run("counts distinct successful submissions", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + users, _, questions := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // User 0: 2 successful submissions (questions 0, 1) + _, err := entClient.Submission.Create(). + SetUser(users[0]). + SetQuestion(questions[0]). + SetSubmittedCode("SELECT 1"). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + _, err = entClient.Submission.Create(). + SetUser(users[0]). + SetQuestion(questions[1]). + SetSubmittedCode("SELECT 1"). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(2 * time.Hour)). + Save(ctx) + require.NoError(t, err) + + // User 1: 1 successful submission (question 0) + _, err = entClient.Submission.Create(). + SetUser(users[1]). + SetQuestion(questions[0]). + SetSubmittedCode("SELECT 1"). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // User 1: 1 failed submission (should not count) + _, err = entClient.Submission.Create(). + SetUser(users[1]). + SetQuestion(questions[1]). + SetSubmittedCode("SELECT wrong"). + SetStatus(entSubmission.StatusFailed). + SetSubmittedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // User 2: 3 successful submissions (all questions) + for _, q := range questions { + _, err = entClient.Submission.Create(). + SetUser(users[2]). + SetQuestion(q). + SetSubmittedCode("SELECT 1"). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query + query := `query { + ranking( + first: 10, + filter: { by: COMPLETED_QUESTIONS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + totalCount + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + } + } + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 3, resp.Ranking.TotalCount) + + // Check order: User 2 (3), User 0 (2), User 1 (1) + assert.Equal(t, "User 3", resp.Ranking.Edges[0].Node.Name) + assert.Equal(t, "User 1", resp.Ranking.Edges[1].Node.Name) + assert.Equal(t, "User 2", resp.Ranking.Edges[2].Node.Name) + }) + + t.Run("does not double count same question", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + users, _, questions := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // User 0: multiple successful submissions for the same question (should count as 1) + for i := 0; i < 3; i++ { + _, err := entClient.Submission.Create(). + SetUser(users[0]). + SetQuestion(questions[0]). + SetSubmittedCode("SELECT " + strconv.Itoa(i)). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(time.Duration(i) * time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query + query := `query { + ranking( + first: 10, + filter: { by: COMPLETED_QUESTIONS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + totalCount + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + } + } + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify response - should count as 1 completed question, not 3 + require.NoError(t, err) + assert.Equal(t, 1, resp.Ranking.TotalCount) + assert.Equal(t, "User 1", resp.Ranking.Edges[0].Node.Name) + }) +} + +func TestQueryResolver_Ranking_Pagination(t *testing.T) { + t.Run("respects first parameter", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for all users + for i, user := range users { + _, err := entClient.Point.Create(). + SetUser(user). + SetPoints((5 - i) * 100). // Decreasing points + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query with first: 3 + query := `query { + ranking( + first: 3, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + } + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + PageInfo struct { + HasNextPage bool + HasPreviousPage bool + } + } + } + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 5, resp.Ranking.TotalCount) + assert.Equal(t, 3, len(resp.Ranking.Edges)) + assert.True(t, resp.Ranking.PageInfo.HasNextPage) + assert.False(t, resp.Ranking.PageInfo.HasPreviousPage) + }) + + t.Run("cursor pagination works", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for all users + for i, user := range users { + _, err := entClient.Point.Create(). + SetUser(user). + SetPoints((5 - i) * 100). + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // First query - get first 2 + query1 := `query { + ranking( + first: 2, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + id + name + } + cursor + } + pageInfo { + endCursor + hasNextPage + } + } + }` + + var resp1 struct { + Ranking struct { + Edges []struct { + Node struct { + ID string + Name string + } + Cursor string + } + PageInfo struct { + EndCursor string + HasNextPage bool + } + } + } + + err := gqlClient.Post(query1, &resp1, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + require.NoError(t, err) + assert.Equal(t, 2, len(resp1.Ranking.Edges)) + assert.True(t, resp1.Ranking.PageInfo.HasNextPage) + + // Get the cursor from the last edge + cursor := resp1.Ranking.PageInfo.EndCursor + + // Second query - get next 2 using cursor + query2 := `query($cursor: Cursor!) { + ranking( + first: 2, + after: $cursor, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + pageInfo { + hasNextPage + hasPreviousPage + } + } + }` + + var resp2 struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + PageInfo struct { + HasNextPage bool + HasPreviousPage bool + } + } + } + + err = gqlClient.Post(query2, &resp2, + client.Var("cursor", cursor), + func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + require.NoError(t, err) + assert.Equal(t, 2, len(resp2.Ranking.Edges)) + assert.True(t, resp2.Ranking.PageInfo.HasPreviousPage) + assert.True(t, resp2.Ranking.PageInfo.HasNextPage) + + // Verify we got different users + assert.NotEqual(t, resp1.Ranking.Edges[0].Node.Name, resp2.Ranking.Edges[0].Node.Name) + }) +} + +func TestQueryResolver_Ranking_EdgeCases(t *testing.T) { + t.Run("empty results", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + // Don't create any data + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query + query := `query { + ranking( + first: 10, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + } + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + PageInfo struct { + HasNextPage bool + HasPreviousPage bool + } + } + } + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 0, resp.Ranking.TotalCount) + assert.Equal(t, 0, len(resp.Ranking.Edges)) + assert.False(t, resp.Ranking.PageInfo.HasNextPage) + assert.False(t, resp.Ranking.PageInfo.HasPreviousPage) + }) + + t.Run("single user", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for only one user + _, err := entClient.Point.Create(). + SetUser(users[0]). + SetPoints(100). + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query + query := `query { + ranking( + first: 10, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + totalCount + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + } + } + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 1, resp.Ranking.TotalCount) + assert.Equal(t, 1, len(resp.Ranking.Edges)) + assert.Equal(t, "User 1", resp.Ranking.Edges[0].Node.Name) + }) +} + +func TestQueryResolver_Ranking_Authorization(t *testing.T) { + t.Run("unauthenticated", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query without auth + query := `query { + ranking( + first: 10, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + } + } + + err := gqlClient.Post(query, &resp) + + // Verify error + require.Error(t, err) + require.Contains(t, err.Error(), defs.CodeUnauthorized) + }) + + t.Run("insufficient scope", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query with wrong scope + query := `query { + ranking( + first: 10, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + } + } + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:write"}, // wrong scope + })) + }) + + // Verify error + require.Error(t, err) + require.Contains(t, err.Error(), defs.CodeForbidden) + }) + + t.Run("with correct scope", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + // Create test server + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Execute query with correct scope + query := `query { + ranking( + first: 10, + filter: { by: POINTS, order: DESC, period: DAILY } + ) { + edges { + node { + name + } + } + totalCount + } + }` + + var resp struct { + Ranking struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + } + } + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, + })) + }) + + // Verify no error + require.NoError(t, err) + }) +} diff --git a/graph/resolver.go b/graph/resolver.go index e116f82..4fe810f 100644 --- a/graph/resolver.go +++ b/graph/resolver.go @@ -10,6 +10,7 @@ import ( "github.com/database-playground/backend-v2/graph/directive" "github.com/database-playground/backend-v2/internal/auth" "github.com/database-playground/backend-v2/internal/events" + "github.com/database-playground/backend-v2/internal/ranking" "github.com/database-playground/backend-v2/internal/sqlrunner" "github.com/database-playground/backend-v2/internal/submission" "github.com/database-playground/backend-v2/internal/useraccount" @@ -29,11 +30,12 @@ type Resolver struct { eventService *events.EventService submissionService *submission.SubmissionService + rankingService *ranking.Service } // NewResolver creates a new resolver. -func NewResolver(ent *ent.Client, auth auth.Storage, sqlrunner *sqlrunner.SqlRunner, useraccount *useraccount.Context, eventService *events.EventService, submissionService *submission.SubmissionService) *Resolver { - return &Resolver{ent, auth, sqlrunner, useraccount, eventService, submissionService} +func NewResolver(ent *ent.Client, auth auth.Storage, sqlrunner *sqlrunner.SqlRunner, useraccount *useraccount.Context, eventService *events.EventService, submissionService *submission.SubmissionService, rankingService *ranking.Service) *Resolver { + return &Resolver{ent, auth, sqlrunner, useraccount, eventService, submissionService, rankingService} } // NewSchema creates a graphql executable schema. @@ -44,9 +46,10 @@ func NewSchema( useraccount *useraccount.Context, eventService *events.EventService, submissionService *submission.SubmissionService, + rankingService *ranking.Service, ) graphql.ExecutableSchema { return NewExecutableSchema(Config{ - Resolvers: NewResolver(ent, auth, sqlrunner, useraccount, eventService, submissionService), + Resolvers: NewResolver(ent, auth, sqlrunner, useraccount, eventService, submissionService, rankingService), Directives: DirectiveRoot{ Scope: directive.ScopeDirective, }, diff --git a/graph/user.resolvers_test.go b/graph/user.resolvers_test.go index 162bf86..986e3b3 100644 --- a/graph/user.resolvers_test.go +++ b/graph/user.resolvers_test.go @@ -14,6 +14,7 @@ import ( "github.com/database-playground/backend-v2/graph/directive" "github.com/database-playground/backend-v2/internal/auth" "github.com/database-playground/backend-v2/internal/events" + "github.com/database-playground/backend-v2/internal/ranking" "github.com/database-playground/backend-v2/internal/submission" "github.com/database-playground/backend-v2/internal/testhelper" "github.com/database-playground/backend-v2/internal/useraccount" @@ -57,8 +58,9 @@ func NewTestResolver(t *testing.T, entClient *ent.Client, authStorage auth.Stora submissionService := submission.NewSubmissionService(entClient, eventService, sqlrunner) useraccountCtx := useraccount.NewContext(entClient, authStorage, eventService) + rankingService := ranking.NewService(entClient) - return NewResolver(entClient, authStorage, sqlrunner, useraccountCtx, eventService, submissionService) + return NewResolver(entClient, authStorage, sqlrunner, useraccountCtx, eventService, submissionService, rankingService) } func TestMutationResolver_LogoutAll(t *testing.T) { diff --git a/internal/ranking/README.md b/internal/ranking/README.md new file mode 100644 index 0000000..d27377c --- /dev/null +++ b/internal/ranking/README.md @@ -0,0 +1,3 @@ +# Ranking Package + +The `ranking` package provides functionality for calculating and retrieving user rankings based on various metrics. diff --git a/internal/ranking/ranking.go b/internal/ranking/ranking.go new file mode 100644 index 0000000..6baea52 --- /dev/null +++ b/internal/ranking/ranking.go @@ -0,0 +1,230 @@ +package ranking + +import ( + "context" + "fmt" + "sort" + "time" + + "entgo.io/contrib/entgql" + "entgo.io/ent/dialect/sql" + "github.com/database-playground/backend-v2/ent" + "github.com/database-playground/backend-v2/ent/point" + entSubmission "github.com/database-playground/backend-v2/ent/submission" + "github.com/database-playground/backend-v2/ent/user" + "github.com/database-playground/backend-v2/graph/model" + "github.com/database-playground/backend-v2/models" +) + +// Service handles ranking operations +type Service struct { + client *ent.Client +} + +// NewService creates a new ranking service +func NewService(client *ent.Client) *Service { + return &Service{ + client: client, + } +} + +// GetRanking retrieves the ranking based on the provided filter and pagination parameters +func (s *Service) GetRanking(ctx context.Context, first *int, after *entgql.Cursor[int], filter model.RankingFilter) (*model.RankingConnection, error) { + // Calculate the time range based on the period + timeRange := s.getTimeRange(filter.Period) + + // Get all users with their scores + var userScores []models.UserScore + var err error + + switch filter.By { + case model.RankingByPoints: + userScores, err = s.getUserScoresByPoints(ctx, timeRange) + case model.RankingByCompletedQuestions: + userScores, err = s.getUserScoresByCompletedQuestions(ctx, timeRange) + default: + return nil, fmt.Errorf("unsupported ranking type: %s", filter.By) + } + + if err != nil { + return nil, fmt.Errorf("failed to get user scores: %w", err) + } + + // Sort based on order + s.sortUserScores(userScores, filter.Order) + + // Calculate total count before pagination + totalCount := len(userScores) + + // Apply cursor pagination + startIdx := 0 + if after != nil { + // Find the position after the cursor + afterID := after.ID + for i, us := range userScores { + if us.UserID == afterID { + startIdx = i + 1 + break + } + } + } + + // Apply limit + limit := 10 // default limit + if first != nil && *first > 0 { + limit = *first + } + + endIdx := min(startIdx+limit, len(userScores)) + + paginatedScores := userScores[startIdx:endIdx] + + // Fetch user entities for the paginated results + userIDs := make([]int, len(paginatedScores)) + for i, us := range paginatedScores { + userIDs[i] = us.UserID + } + + users, err := s.client.User.Query(). + Where(user.IDIn(userIDs...)). + All(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch users: %w", err) + } + + // Create a map for quick lookup + userMap := make(map[int]*ent.User) + for _, u := range users { + userMap[u.ID] = u + } + + // Build edges + edges := make([]*model.RankingEdge, 0, len(paginatedScores)) + for _, us := range paginatedScores { + if user, ok := userMap[us.UserID]; ok { + cursor := entgql.Cursor[int]{ID: us.UserID} + edges = append(edges, &model.RankingEdge{ + Node: user, + Cursor: cursor, + }) + } + } + + // Build page info + pageInfo := &entgql.PageInfo[int]{ + HasNextPage: endIdx < len(userScores), + HasPreviousPage: startIdx > 0, + } + + if len(edges) > 0 { + pageInfo.StartCursor = &edges[0].Cursor + pageInfo.EndCursor = &edges[len(edges)-1].Cursor + } + + return &model.RankingConnection{ + Edges: edges, + PageInfo: pageInfo, + TotalCount: totalCount, + }, nil +} + +// getTimeRange calculates the start time based on the period +func (s *Service) getTimeRange(period model.RankingPeriod) time.Time { + now := time.Now() + + switch period { + case model.RankingPeriodDaily: + // Start of today + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + case model.RankingPeriodWeekly: + // Start of the week (Monday) + weekday := now.Weekday() + daysToMonday := int(weekday - time.Monday) + if daysToMonday < 0 { + daysToMonday += 7 + } + startOfWeek := now.AddDate(0, 0, -daysToMonday) + return time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location()) + default: + // Default to daily + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + } +} + +// getUserScoresByPoints gets user scores based on total points in the time range +func (s *Service) getUserScoresByPoints(ctx context.Context, since time.Time) ([]models.UserScore, error) { + var results []struct { + UserID int `json:"user_points"` + TotalScore int `json:"total_score"` + } + + err := s.client.Point.Query(). + Where(point.GrantedAtGTE(since)). + GroupBy("user_points"). + Aggregate(func(sel *sql.Selector) string { + return sql.As(sql.Sum(point.FieldPoints), "total_score") + }). + Scan(ctx, &results) + + if err != nil { + return nil, err + } + + userScores := make([]models.UserScore, len(results)) + for i, r := range results { + userScores[i] = models.UserScore{ + UserID: r.UserID, + Score: r.TotalScore, + } + } + + return userScores, nil +} + +// getUserScoresByCompletedQuestions gets user scores based on completed questions in the time range +func (s *Service) getUserScoresByCompletedQuestions(ctx context.Context, since time.Time) ([]models.UserScore, error) { + var results []struct { + UserID int `json:"user_submissions"` + CompletedQuests int `json:"completed_quests"` + } + + // Count distinct successful submissions per user + err := s.client.Submission.Query(). + Where( + entSubmission.StatusEQ(entSubmission.StatusSuccess), + entSubmission.SubmittedAtGTE(since), + ). + GroupBy("user_submissions"). + Aggregate(func(sel *sql.Selector) string { + // Count distinct questions + return sql.As(fmt.Sprintf("COUNT(DISTINCT %s)", "question_submissions"), "completed_quests") + }). + Scan(ctx, &results) + + if err != nil { + return nil, err + } + + userScores := make([]models.UserScore, len(results)) + for i, r := range results { + userScores[i] = models.UserScore{ + UserID: r.UserID, + Score: r.CompletedQuests, + } + } + + return userScores, nil +} + +// sortUserScores sorts user scores in place based on the order +func (s *Service) sortUserScores(scores []models.UserScore, order model.RankingOrder) { + if order == model.RankingOrderDesc { + sort.Slice(scores, func(i, j int) bool { + return scores[i].Score > scores[j].Score + }) + } else { + sort.Slice(scores, func(i, j int) bool { + return scores[i].Score < scores[j].Score + }) + } +} diff --git a/internal/ranking/ranking_test.go b/internal/ranking/ranking_test.go new file mode 100644 index 0000000..5b1187f --- /dev/null +++ b/internal/ranking/ranking_test.go @@ -0,0 +1,548 @@ +package ranking + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/database-playground/backend-v2/ent" + entQuestion "github.com/database-playground/backend-v2/ent/question" + entSubmission "github.com/database-playground/backend-v2/ent/submission" + "github.com/database-playground/backend-v2/graph/model" + "github.com/database-playground/backend-v2/internal/testhelper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + _ "github.com/mattn/go-sqlite3" +) + +// setupTestRankingData creates test users, points, and submissions for ranking tests +func setupTestRankingData(t *testing.T, entClient *ent.Client) ([]*ent.User, *ent.Database, []*ent.Question) { + t.Helper() + + ctx := context.Background() + + // Create test group + group, err := entClient.Group.Create(). + SetName("Test Group"). + Save(ctx) + require.NoError(t, err) + + // Create test database + database, err := entClient.Database.Create(). + SetSlug("test_db"). + SetSchema(`{"tables": []}`). + SetRelationFigure("test_figure"). + Save(ctx) + require.NoError(t, err) + + // Create test questions + questions := make([]*ent.Question, 3) + for i := range questions { + q, err := entClient.Question.Create(). + SetCategory("test"). + SetTitle("Question " + strconv.Itoa(i+1)). + SetDescription("Test question"). + SetReferenceAnswer("SELECT 1"). + SetDifficulty(entQuestion.DifficultyEasy). + SetDatabase(database). + Save(ctx) + require.NoError(t, err) + questions[i] = q + } + + // Create test users + users := make([]*ent.User, 5) + for i := range 5 { + user, err := entClient.User.Create(). + SetName("User " + strconv.Itoa(i+1)). + SetEmail("user" + strconv.Itoa(i+1) + "@example.com"). + SetGroup(group). + Save(ctx) + require.NoError(t, err) + users[i] = user + } + + return users, database, questions +} + +func TestService_GetRanking_ByPoints_Daily(t *testing.T) { + t.Run("descending order", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for users (today) + // User 0: 150 points, User 1: 200 points, User 2: 100 points, User 3: 50 points, User 4: 0 points + pointsData := []struct { + userIdx int + points int + }{ + {0, 150}, + {1, 200}, + {2, 100}, + {3, 50}, + } + + for _, data := range pointsData { + _, err := entClient.Point.Create(). + SetUser(users[data.userIdx]). + SetPoints(data.points). + SetGrantedAt(today.Add(time.Hour)). + SetDescription("Daily points"). + Save(ctx) + require.NoError(t, err) + } + + // Get ranking + filter := model.RankingFilter{ + By: model.RankingByPoints, + Order: model.RankingOrderDesc, + Period: model.RankingPeriodDaily, + } + first := 10 + result, err := service.GetRanking(ctx, &first, nil, filter) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 4, result.TotalCount) + assert.Equal(t, 4, len(result.Edges)) + assert.False(t, result.PageInfo.HasNextPage) + assert.False(t, result.PageInfo.HasPreviousPage) + + // Check order: User 1 (200), User 0 (150), User 2 (100), User 3 (50) + assert.Equal(t, "User 2", result.Edges[0].Node.Name) + assert.Equal(t, "User 1", result.Edges[1].Node.Name) + assert.Equal(t, "User 3", result.Edges[2].Node.Name) + assert.Equal(t, "User 4", result.Edges[3].Node.Name) + }) + + t.Run("ascending order", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for users (today) + pointsData := []struct { + userIdx int + points int + }{ + {0, 150}, + {1, 200}, + {2, 100}, + } + + for _, data := range pointsData { + _, err := entClient.Point.Create(). + SetUser(users[data.userIdx]). + SetPoints(data.points). + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // Get ranking + filter := model.RankingFilter{ + By: model.RankingByPoints, + Order: model.RankingOrderAsc, + Period: model.RankingPeriodDaily, + } + first := 10 + result, err := service.GetRanking(ctx, &first, nil, filter) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 3, result.TotalCount) + + // Check order: User 2 (100), User 0 (150), User 1 (200) + assert.Equal(t, "User 3", result.Edges[0].Node.Name) + assert.Equal(t, "User 1", result.Edges[1].Node.Name) + assert.Equal(t, "User 2", result.Edges[2].Node.Name) + }) + + t.Run("filters out yesterday's points", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + yesterday := today.Add(-24 * time.Hour) + + // Create points for yesterday (should not be included) + _, err := entClient.Point.Create(). + SetUser(users[0]). + SetPoints(500). + SetGrantedAt(yesterday.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // Create points for today + _, err = entClient.Point.Create(). + SetUser(users[1]). + SetPoints(100). + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // Get ranking + filter := model.RankingFilter{ + By: model.RankingByPoints, + Order: model.RankingOrderDesc, + Period: model.RankingPeriodDaily, + } + first := 10 + result, err := service.GetRanking(ctx, &first, nil, filter) + + // Verify response - only user 1 should appear + require.NoError(t, err) + assert.Equal(t, 1, result.TotalCount) + assert.Equal(t, "User 2", result.Edges[0].Node.Name) + }) +} + +func TestService_GetRanking_ByPoints_Weekly(t *testing.T) { + t.Run("includes entire week", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + + // Calculate start of week (Monday) + weekday := now.Weekday() + daysToMonday := int(weekday - time.Monday) + if daysToMonday < 0 { + daysToMonday += 7 + } + startOfWeek := now.AddDate(0, 0, -daysToMonday) + startOfWeek = time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location()) + + // Create points throughout the week + _, err := entClient.Point.Create(). + SetUser(users[0]). + SetPoints(100). + SetGrantedAt(startOfWeek.Add(time.Hour)). // Monday + Save(ctx) + require.NoError(t, err) + + _, err = entClient.Point.Create(). + SetUser(users[0]). + SetPoints(50). + SetGrantedAt(startOfWeek.Add(3 * 24 * time.Hour)). // Thursday + Save(ctx) + require.NoError(t, err) + + _, err = entClient.Point.Create(). + SetUser(users[1]). + SetPoints(200). + SetGrantedAt(startOfWeek.Add(5 * 24 * time.Hour)). // Saturday + Save(ctx) + require.NoError(t, err) + + // Create points from last week (should not be included) + _, err = entClient.Point.Create(). + SetUser(users[2]). + SetPoints(1000). + SetGrantedAt(startOfWeek.Add(-7 * 24 * time.Hour)). + Save(ctx) + require.NoError(t, err) + + // Get ranking + filter := model.RankingFilter{ + By: model.RankingByPoints, + Order: model.RankingOrderDesc, + Period: model.RankingPeriodWeekly, + } + first := 10 + result, err := service.GetRanking(ctx, &first, nil, filter) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 2, result.TotalCount) + + // User 1 (200), User 0 (150 = 100 + 50) + assert.Equal(t, "User 2", result.Edges[0].Node.Name) + assert.Equal(t, "User 1", result.Edges[1].Node.Name) + }) +} + +func TestService_GetRanking_ByCompletedQuestions(t *testing.T) { + t.Run("counts distinct successful submissions", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + users, _, questions := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // User 0: 2 successful submissions (questions 0, 1) + _, err := entClient.Submission.Create(). + SetUser(users[0]). + SetQuestion(questions[0]). + SetSubmittedCode("SELECT 1"). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + _, err = entClient.Submission.Create(). + SetUser(users[0]). + SetQuestion(questions[1]). + SetSubmittedCode("SELECT 1"). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(2 * time.Hour)). + Save(ctx) + require.NoError(t, err) + + // User 1: 1 successful submission (question 0) + _, err = entClient.Submission.Create(). + SetUser(users[1]). + SetQuestion(questions[0]). + SetSubmittedCode("SELECT 1"). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // User 1: 1 failed submission (should not count) + _, err = entClient.Submission.Create(). + SetUser(users[1]). + SetQuestion(questions[1]). + SetSubmittedCode("SELECT wrong"). + SetStatus(entSubmission.StatusFailed). + SetSubmittedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // User 2: 3 successful submissions (all questions) + for _, q := range questions { + _, err = entClient.Submission.Create(). + SetUser(users[2]). + SetQuestion(q). + SetSubmittedCode("SELECT 1"). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // Get ranking + filter := model.RankingFilter{ + By: model.RankingByCompletedQuestions, + Order: model.RankingOrderDesc, + Period: model.RankingPeriodDaily, + } + first := 10 + result, err := service.GetRanking(ctx, &first, nil, filter) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 3, result.TotalCount) + + // Check order: User 2 (3), User 0 (2), User 1 (1) + assert.Equal(t, "User 3", result.Edges[0].Node.Name) + assert.Equal(t, "User 1", result.Edges[1].Node.Name) + assert.Equal(t, "User 2", result.Edges[2].Node.Name) + }) + + t.Run("does not double count same question", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + users, _, questions := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // User 0: multiple successful submissions for the same question (should count as 1) + for i := 0; i < 3; i++ { + _, err := entClient.Submission.Create(). + SetUser(users[0]). + SetQuestion(questions[0]). + SetSubmittedCode("SELECT " + strconv.Itoa(i)). + SetStatus(entSubmission.StatusSuccess). + SetSubmittedAt(today.Add(time.Duration(i) * time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // Get ranking + filter := model.RankingFilter{ + By: model.RankingByCompletedQuestions, + Order: model.RankingOrderDesc, + Period: model.RankingPeriodDaily, + } + first := 10 + result, err := service.GetRanking(ctx, &first, nil, filter) + + // Verify response - should count as 1 completed question, not 3 + require.NoError(t, err) + assert.Equal(t, 1, result.TotalCount) + assert.Equal(t, "User 1", result.Edges[0].Node.Name) + }) +} + +func TestService_GetRanking_Pagination(t *testing.T) { + t.Run("respects first parameter", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for all users + for i, user := range users { + _, err := entClient.Point.Create(). + SetUser(user). + SetPoints((5 - i) * 100). // Decreasing points + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // Get ranking + filter := model.RankingFilter{ + By: model.RankingByPoints, + Order: model.RankingOrderDesc, + Period: model.RankingPeriodDaily, + } + first := 3 + result, err := service.GetRanking(ctx, &first, nil, filter) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 5, result.TotalCount) + assert.Equal(t, 3, len(result.Edges)) + assert.True(t, result.PageInfo.HasNextPage) + assert.False(t, result.PageInfo.HasPreviousPage) + }) + + t.Run("cursor pagination works", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for all users + for i, user := range users { + _, err := entClient.Point.Create(). + SetUser(user). + SetPoints((5 - i) * 100). + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + } + + // First query - get first 2 + filter := model.RankingFilter{ + By: model.RankingByPoints, + Order: model.RankingOrderDesc, + Period: model.RankingPeriodDaily, + } + first := 2 + result1, err := service.GetRanking(ctx, &first, nil, filter) + + require.NoError(t, err) + assert.Equal(t, 2, len(result1.Edges)) + assert.True(t, result1.PageInfo.HasNextPage) + + // Get the cursor from the last edge + cursor := result1.PageInfo.EndCursor + + // Second query - get next 2 using cursor + result2, err := service.GetRanking(ctx, &first, cursor, filter) + + require.NoError(t, err) + assert.Equal(t, 2, len(result2.Edges)) + assert.True(t, result2.PageInfo.HasPreviousPage) + assert.True(t, result2.PageInfo.HasNextPage) + + // Verify we got different users + assert.NotEqual(t, result1.Edges[0].Node.Name, result2.Edges[0].Node.Name) + }) +} + +func TestService_GetRanking_EdgeCases(t *testing.T) { + t.Run("empty results", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + ctx := context.Background() + + // Get ranking without any data + filter := model.RankingFilter{ + By: model.RankingByPoints, + Order: model.RankingOrderDesc, + Period: model.RankingPeriodDaily, + } + first := 10 + result, err := service.GetRanking(ctx, &first, nil, filter) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 0, result.TotalCount) + assert.Equal(t, 0, len(result.Edges)) + assert.False(t, result.PageInfo.HasNextPage) + assert.False(t, result.PageInfo.HasPreviousPage) + }) + + t.Run("single user", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + service := NewService(entClient) + + users, _, _ := setupTestRankingData(t, entClient) + + ctx := context.Background() + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Create points for only one user + _, err := entClient.Point.Create(). + SetUser(users[0]). + SetPoints(100). + SetGrantedAt(today.Add(time.Hour)). + Save(ctx) + require.NoError(t, err) + + // Get ranking + filter := model.RankingFilter{ + By: model.RankingByPoints, + Order: model.RankingOrderDesc, + Period: model.RankingPeriodDaily, + } + first := 10 + result, err := service.GetRanking(ctx, &first, nil, filter) + + // Verify response + require.NoError(t, err) + assert.Equal(t, 1, result.TotalCount) + assert.Equal(t, 1, len(result.Edges)) + assert.Equal(t, "User 1", result.Edges[0].Node.Name) + }) +} diff --git a/models/ranking.go b/models/ranking.go new file mode 100644 index 0000000..92bc5f4 --- /dev/null +++ b/models/ranking.go @@ -0,0 +1,7 @@ +package models + +// UserScore represents a user's score for ranking purposes +type UserScore struct { + UserID int + Score int +} From 424e92ce3d7ce4d8e9bc9c7fdf1a450b0076acb9 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 14 Oct 2025 01:40:09 +0800 Subject: [PATCH 2/7] fix(events): handle PostHog event error --- internal/events/events.go | 5 ++++- internal/events/points.go | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/events/events.go b/internal/events/events.go index a395ca8..1c8edf2 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -50,12 +50,15 @@ func (s *EventService) TriggerEvent(ctx context.Context, event Event) { if s.posthogClient != nil { slog.Debug("sending event to PostHog", "event_type", event.Type, "user_id", event.UserID) - s.posthogClient.Enqueue(posthog.Capture{ + err = s.posthogClient.Enqueue(posthog.Capture{ DistinctId: strconv.Itoa(event.UserID), Event: string(event.Type), Timestamp: time.Now(), Properties: event.Payload, }) + if err != nil { + slog.Error("failed to send event to PostHog", "error", err) + } } } diff --git a/internal/events/points.go b/internal/events/points.go index 86de3c6..40e3f11 100644 --- a/internal/events/points.go +++ b/internal/events/points.go @@ -398,10 +398,13 @@ func (d *PointsGranter) grantPoint(ctx context.Context, userID int, questionID i Exec(ctx) if err != nil { if d.posthogClient != nil { - d.posthogClient.Enqueue(posthog.NewDefaultException( + err = d.posthogClient.Enqueue(posthog.NewDefaultException( time.Now(), strconv.Itoa(userID), "failed to grant point", err.Error(), )) + if err != nil { + slog.Error("failed to send event to PostHog", "error", err) + } } return err @@ -418,12 +421,15 @@ func (d *PointsGranter) grantPoint(ctx context.Context, userID int, questionID i slog.Debug("sending event to PostHog", "event_type", EventTypeGrantPoint, "user_id", userID) - d.posthogClient.Enqueue(posthog.Capture{ + err = d.posthogClient.Enqueue(posthog.Capture{ DistinctId: strconv.Itoa(userID), Event: string(EventTypeGrantPoint), Timestamp: time.Now(), Properties: properties, }) + if err != nil { + slog.Error("failed to send event to PostHog", "error", err) + } } return nil From 5763d437b5df1eef64aae6fba27e86f9fb6c08a0 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 14 Oct 2025 01:40:19 +0800 Subject: [PATCH 3/7] style: reformat codebase --- graph/ent.resolvers.go | 10 ++++++---- internal/ranking/ranking.go | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/graph/ent.resolvers.go b/graph/ent.resolvers.go index 9be0531..afbe3ef 100644 --- a/graph/ent.resolvers.go +++ b/graph/ent.resolvers.go @@ -94,7 +94,9 @@ func (r *Resolver) Question() QuestionResolver { return &questionResolver{r} } // User returns UserResolver implementation. func (r *Resolver) User() UserResolver { return &userResolver{r} } -type databaseResolver struct{ *Resolver } -type queryResolver struct{ *Resolver } -type questionResolver struct{ *Resolver } -type userResolver struct{ *Resolver } +type ( + databaseResolver struct{ *Resolver } + queryResolver struct{ *Resolver } + questionResolver struct{ *Resolver } + userResolver struct{ *Resolver } +) diff --git a/internal/ranking/ranking.go b/internal/ranking/ranking.go index 6baea52..04ca441 100644 --- a/internal/ranking/ranking.go +++ b/internal/ranking/ranking.go @@ -165,7 +165,6 @@ func (s *Service) getUserScoresByPoints(ctx context.Context, since time.Time) ([ return sql.As(sql.Sum(point.FieldPoints), "total_score") }). Scan(ctx, &results) - if err != nil { return nil, err } @@ -200,7 +199,6 @@ func (s *Service) getUserScoresByCompletedQuestions(ctx context.Context, since t return sql.As(fmt.Sprintf("COUNT(DISTINCT %s)", "question_submissions"), "completed_quests") }). Scan(ctx, &results) - if err != nil { return nil, err } From af92229615233b5d2f88c79b7d6f3ffacb1a60a6 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 14 Oct 2025 01:46:24 +0800 Subject: [PATCH 4/7] ci: add Lint and Check Formatting check --- .github/workflows/lint.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3df5ae4 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,34 @@ +name: Lint and Check Formatting + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint and Check Formatting + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + cache: true + + - name: Install dependencies and tools + run: | + go mod download + go install mvdan.cc/gofumpt@latest + + - name: Lint with golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + + - name: Run gofumpt + run: gofumpt -d . From 7d25d092065f06ba5cd726424f6270545391ea71 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 14 Oct 2025 01:50:39 +0800 Subject: [PATCH 5/7] test: no confuse comments --- internal/ranking/ranking_test.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/ranking/ranking_test.go b/internal/ranking/ranking_test.go index 5b1187f..e4b6b28 100644 --- a/internal/ranking/ranking_test.go +++ b/internal/ranking/ranking_test.go @@ -79,7 +79,7 @@ func TestService_GetRanking_ByPoints_Daily(t *testing.T) { today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // Create points for users (today) - // User 0: 150 points, User 1: 200 points, User 2: 100 points, User 3: 50 points, User 4: 0 points + // users[0] (User 1): 150 points, users[1] (User 2): 200 points, users[2] (User 3): 100 points, users[3] (User 4): 50 points pointsData := []struct { userIdx int points int @@ -116,7 +116,7 @@ func TestService_GetRanking_ByPoints_Daily(t *testing.T) { assert.False(t, result.PageInfo.HasNextPage) assert.False(t, result.PageInfo.HasPreviousPage) - // Check order: User 1 (200), User 0 (150), User 2 (100), User 3 (50) + // Check order: User 2 (200), User 1 (150), User 3 (100), User 4 (50) assert.Equal(t, "User 2", result.Edges[0].Node.Name) assert.Equal(t, "User 1", result.Edges[1].Node.Name) assert.Equal(t, "User 3", result.Edges[2].Node.Name) @@ -165,7 +165,7 @@ func TestService_GetRanking_ByPoints_Daily(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, result.TotalCount) - // Check order: User 2 (100), User 0 (150), User 1 (200) + // Check order: User 3 (100), User 1 (150), User 2 (200) assert.Equal(t, "User 3", result.Edges[0].Node.Name) assert.Equal(t, "User 1", result.Edges[1].Node.Name) assert.Equal(t, "User 2", result.Edges[2].Node.Name) @@ -276,7 +276,7 @@ func TestService_GetRanking_ByPoints_Weekly(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, result.TotalCount) - // User 1 (200), User 0 (150 = 100 + 50) + // User 2 (200), User 1 (150 = 100 + 50) assert.Equal(t, "User 2", result.Edges[0].Node.Name) assert.Equal(t, "User 1", result.Edges[1].Node.Name) }) @@ -293,7 +293,7 @@ func TestService_GetRanking_ByCompletedQuestions(t *testing.T) { now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - // User 0: 2 successful submissions (questions 0, 1) + // users[0] (User 1): 2 successful submissions (questions 0, 1) _, err := entClient.Submission.Create(). SetUser(users[0]). SetQuestion(questions[0]). @@ -312,7 +312,7 @@ func TestService_GetRanking_ByCompletedQuestions(t *testing.T) { Save(ctx) require.NoError(t, err) - // User 1: 1 successful submission (question 0) + // users[1] (User 2): 1 successful submission (question 0) _, err = entClient.Submission.Create(). SetUser(users[1]). SetQuestion(questions[0]). @@ -322,7 +322,7 @@ func TestService_GetRanking_ByCompletedQuestions(t *testing.T) { Save(ctx) require.NoError(t, err) - // User 1: 1 failed submission (should not count) + // users[1] (User 2): 1 failed submission (should not count) _, err = entClient.Submission.Create(). SetUser(users[1]). SetQuestion(questions[1]). @@ -332,7 +332,7 @@ func TestService_GetRanking_ByCompletedQuestions(t *testing.T) { Save(ctx) require.NoError(t, err) - // User 2: 3 successful submissions (all questions) + // users[2] (User 3): 3 successful submissions (all questions) for _, q := range questions { _, err = entClient.Submission.Create(). SetUser(users[2]). @@ -357,7 +357,7 @@ func TestService_GetRanking_ByCompletedQuestions(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, result.TotalCount) - // Check order: User 2 (3), User 0 (2), User 1 (1) + // Check order: User 3 (3), User 1 (2), User 2 (1) assert.Equal(t, "User 3", result.Edges[0].Node.Name) assert.Equal(t, "User 1", result.Edges[1].Node.Name) assert.Equal(t, "User 2", result.Edges[2].Node.Name) @@ -373,7 +373,7 @@ func TestService_GetRanking_ByCompletedQuestions(t *testing.T) { now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - // User 0: multiple successful submissions for the same question (should count as 1) + // users[0] (User 1): multiple successful submissions for the same question (should count as 1) for i := 0; i < 3; i++ { _, err := entClient.Submission.Create(). SetUser(users[0]). From 81f70a84bc3fb902b998f7a579bac9a1e57cad5d Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 14 Oct 2025 01:51:17 +0800 Subject: [PATCH 6/7] ci: generate GraphQL code before linting --- .github/workflows/lint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3df5ae4..b55891f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,6 +25,9 @@ jobs: go mod download go install mvdan.cc/gofumpt@latest + - name: Generate GraphQL code + run: go generate . + - name: Lint with golangci-lint uses: golangci/golangci-lint-action@v8 with: From ffb3812cc5935f573e36b7009b5e059707918cd2 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 14 Oct 2025 01:59:14 +0800 Subject: [PATCH 7/7] refactor: make gqlgen compatible with gofumpt --- .github/workflows/lint.yml | 8 +++----- generate.go | 1 + go.mod | 2 ++ go.sum | 4 ++++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b55891f..dc59819 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,10 +20,8 @@ jobs: go-version-file: "go.mod" cache: true - - name: Install dependencies and tools - run: | - go mod download - go install mvdan.cc/gofumpt@latest + - name: Install dependencies + run: go mod download - name: Generate GraphQL code run: go generate . @@ -34,4 +32,4 @@ jobs: version: latest - name: Run gofumpt - run: gofumpt -d . + run: go tool gofumpt -d -l . diff --git a/generate.go b/generate.go index 203c781..2b5d66c 100644 --- a/generate.go +++ b/generate.go @@ -2,3 +2,4 @@ package main //go:generate go run -mod=mod ./ent/entc.go //go:generate go tool gqlgen generate +//go:generate go tool gofumpt -w ./graph diff --git a/go.mod b/go.mod index 1a7cc03..f09ab2e 100644 --- a/go.mod +++ b/go.mod @@ -149,10 +149,12 @@ require ( google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + mvdan.cc/gofumpt v0.9.1 // indirect ) tool ( entgo.io/ent/cmd/ent github.com/99designs/gqlgen github.com/99designs/gqlgen/graphql/introspection + mvdan.cc/gofumpt ) diff --git a/go.sum b/go.sum index 4c03344..36e6b5a 100644 --- a/go.sum +++ b/go.sum @@ -108,6 +108,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -373,3 +375,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +mvdan.cc/gofumpt v0.9.1 h1:p5YT2NfFWsYyTieYgwcQ8aKV3xRvFH4uuN/zB2gBbMQ= +mvdan.cc/gofumpt v0.9.1/go.mod h1:3xYtNemnKiXaTh6R4VtlqDATFwBbdXI8lJvH/4qk7mw=