From 2d7e429894ba325f11ffaadbf978012650431320 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 19 Sep 2025 23:28:56 +0800 Subject: [PATCH 1/3] feat: implement SubmissionStatistics --- go.mod | 1 + go.sum | 2 + graph/model/models_gen.go | 13 + graph/question.graphqls | 17 + graph/question.resolvers.go | 59 +++ graph/submission_statistics_test.go | 558 ++++++++++++++++++++++++++++ 6 files changed, 650 insertions(+) create mode 100644 graph/submission_statistics_test.go diff --git a/go.mod b/go.mod index ff91108..e366c44 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.28 github.com/redis/rueidis v1.0.64 + github.com/samber/lo v1.51.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.38.0 github.com/urfave/cli/v3 v3.4.1 diff --git a/go.sum b/go.sum index 2433504..4520377 100644 --- a/go.sum +++ b/go.sum @@ -221,6 +221,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= +github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= diff --git a/graph/model/models_gen.go b/graph/model/models_gen.go index 7ba0844..bc13d1a 100644 --- a/graph/model/models_gen.go +++ b/graph/model/models_gen.go @@ -3,6 +3,7 @@ package model import ( + "github.com/database-playground/backend-v2/ent/question" "github.com/database-playground/backend-v2/models" ) @@ -16,7 +17,19 @@ type ScopeSetFilter struct { Slug *string `json:"slug,omitempty"` } +type SolvedQuestionByDifficulty struct { + Difficulty question.Difficulty `json:"difficulty"` + SolvedQuestions int `json:"solvedQuestions"` +} + type SubmissionResult struct { Result *models.UserSQLExecutionResult `json:"result,omitempty"` Error *string `json:"error,omitempty"` } + +type SubmissionStatistics struct { + TotalQuestions int `json:"totalQuestions"` + AttemptedQuestions int `json:"attemptedQuestions"` + SolvedQuestions int `json:"solvedQuestions"` + SolvedQuestionByDifficulty []*SolvedQuestionByDifficulty `json:"solvedQuestionByDifficulty"` +} diff --git a/graph/question.graphqls b/graph/question.graphqls index 2a96dac..910e0c8 100644 --- a/graph/question.graphqls +++ b/graph/question.graphqls @@ -79,6 +79,23 @@ extend type Question { solved: Boolean! } +extend type User { + submissionStatistics: SubmissionStatistics! +} + +type SubmissionStatistics { + totalQuestions: Int! + attemptedQuestions: Int! + solvedQuestions: Int! + + solvedQuestionByDifficulty: [SolvedQuestionByDifficulty!]! +} + +type SolvedQuestionByDifficulty { + difficulty: QuestionDifficulty! + solvedQuestions: Int! +} + type SQLExecutionResult { columns: [String!]! rows: [[String!]!]! diff --git a/graph/question.resolvers.go b/graph/question.resolvers.go index c1158fe..b53d3d5 100644 --- a/graph/question.resolvers.go +++ b/graph/question.resolvers.go @@ -7,8 +7,10 @@ package graph import ( "context" "errors" + "fmt" "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/ent/user" "github.com/database-playground/backend-v2/graph/defs" @@ -17,6 +19,7 @@ import ( "github.com/database-playground/backend-v2/internal/scope" "github.com/database-playground/backend-v2/internal/submission" "github.com/database-playground/backend-v2/models" + "github.com/samber/lo" ) // CreateQuestion is the resolver for the createQuestion field. @@ -245,6 +248,62 @@ func (r *questionResolver) Solved(ctx context.Context, obj *ent.Question) (bool, return exists, nil } +// SubmissionStatistics is the resolver for the submissionStatistics field. +func (r *userResolver) SubmissionStatistics(ctx context.Context, obj *ent.User) (*model.SubmissionStatistics, error) { + entClient := r.EntClient(ctx) + + type tSQLSolvedQuestionByDifficulty struct { + Difficulty entQuestion.Difficulty `json:"difficulty,omitempty"` + Count int `json:"count,omitempty"` + } + + // total questios + totalQuestions, err := entClient.Question.Query().Count(ctx) + if err != nil { + return nil, fmt.Errorf("retrieving total questions: %w", err) + } + + // attempted + attemptedQuestions, err := entClient.Question.Query().Where( + entQuestion.HasSubmissionsWith(entSubmission.HasUserWith(user.ID(obj.ID))), + ).Count(ctx) + + // solved + solvedQuestions, err := entClient.Question.Query().Where( + entQuestion.HasSubmissionsWith(entSubmission.HasUserWith(user.ID(obj.ID)), entSubmission.StatusEQ(entSubmission.StatusSuccess)), + ).Count(ctx) + if err != nil { + return nil, fmt.Errorf("retrieving solved questions: %w", err) + } + + // solved question by difficulty + var solvedQuestionByDifficulty []tSQLSolvedQuestionByDifficulty + err = entClient.Question.Query().Where( + entQuestion.HasSubmissionsWith( + entSubmission.HasUserWith(user.ID(obj.ID)), + entSubmission.StatusEQ(entSubmission.StatusSuccess), + ), + ). + GroupBy(entQuestion.FieldDifficulty). + Aggregate(ent.Count()). + Scan(ctx, &solvedQuestionByDifficulty) + if err != nil { + return nil, fmt.Errorf("retrieving solved question by difficulty: %w", err) + } + + return &model.SubmissionStatistics{ + TotalQuestions: totalQuestions, + AttemptedQuestions: attemptedQuestions, + SolvedQuestions: solvedQuestions, + SolvedQuestionByDifficulty: lo.Map(solvedQuestionByDifficulty, func(item tSQLSolvedQuestionByDifficulty, _ int) *model.SolvedQuestionByDifficulty { + return &model.SolvedQuestionByDifficulty{ + Difficulty: item.Difficulty, + SolvedQuestions: item.Count, + } + }), + }, nil +} + // Mutation returns MutationResolver implementation. func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } diff --git a/graph/submission_statistics_test.go b/graph/submission_statistics_test.go new file mode 100644 index 0000000..82fc510 --- /dev/null +++ b/graph/submission_statistics_test.go @@ -0,0 +1,558 @@ +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" + "github.com/database-playground/backend-v2/ent/question" + "github.com/database-playground/backend-v2/ent/submission" + "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/require" + + _ "github.com/mattn/go-sqlite3" +) + +func TestUserResolver_SubmissionStatistics(t *testing.T) { + t.Run("user with no submissions", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Create test group and user + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user, err := entClient.User.Create(). + SetName("testuser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create a few questions with different difficulties to ensure totalQuestions > 0 + database := createTestDatabase(t, entClient) + createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyMedium) + createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyHard) + + // Execute query + var resp struct { + User struct { + SubmissionStatistics struct { + TotalQuestions int `json:"totalQuestions"` + AttemptedQuestions int `json:"attemptedQuestions"` + SolvedQuestions int `json:"solvedQuestions"` + SolvedQuestionByDifficulty []struct { + Difficulty string `json:"difficulty"` + SolvedQuestions int `json:"solvedQuestions"` + } `json:"solvedQuestionByDifficulty"` + } `json:"submissionStatistics"` + } `json:"user"` + } + + query := `query { + user(id: ` + strconv.Itoa(user.ID) + `) { + submissionStatistics { + totalQuestions + attemptedQuestions + solvedQuestions + solvedQuestionByDifficulty { + difficulty + solvedQuestions + } + } + } + }` + + 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"}, + })) + }) + + require.NoError(t, err) + require.Equal(t, 3, resp.User.SubmissionStatistics.TotalQuestions) + require.Equal(t, 0, resp.User.SubmissionStatistics.AttemptedQuestions) + require.Equal(t, 0, resp.User.SubmissionStatistics.SolvedQuestions) + require.Empty(t, resp.User.SubmissionStatistics.SolvedQuestionByDifficulty) + }) + + t.Run("user with attempted but no solved questions", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Create test group and user + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user, err := entClient.User.Create(). + SetName("testuser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create test database and questions + database := createTestDatabase(t, entClient) + easyQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + mediumQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyMedium) + createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyHard) // Hard question not attempted + + // Create failed submissions for 2 questions + createTestSubmission(t, entClient, user, easyQuestion, "SELECT * FROM invalid;", submission.StatusFailed, time.Now().Add(-1*time.Hour)) + createTestSubmission(t, entClient, user, mediumQuestion, "SELECT * FROM wrong;", submission.StatusFailed, time.Now()) + + // Execute query + var resp struct { + User struct { + SubmissionStatistics struct { + TotalQuestions int `json:"totalQuestions"` + AttemptedQuestions int `json:"attemptedQuestions"` + SolvedQuestions int `json:"solvedQuestions"` + SolvedQuestionByDifficulty []struct { + Difficulty string `json:"difficulty"` + SolvedQuestions int `json:"solvedQuestions"` + } `json:"solvedQuestionByDifficulty"` + } `json:"submissionStatistics"` + } `json:"user"` + } + + query := `query { + user(id: ` + strconv.Itoa(user.ID) + `) { + submissionStatistics { + totalQuestions + attemptedQuestions + solvedQuestions + solvedQuestionByDifficulty { + difficulty + solvedQuestions + } + } + } + }` + + 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"}, + })) + }) + + require.NoError(t, err) + require.Equal(t, 3, resp.User.SubmissionStatistics.TotalQuestions) + require.Equal(t, 2, resp.User.SubmissionStatistics.AttemptedQuestions) + require.Equal(t, 0, resp.User.SubmissionStatistics.SolvedQuestions) + require.Empty(t, resp.User.SubmissionStatistics.SolvedQuestionByDifficulty) + }) + + t.Run("user with solved questions across all difficulties", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Create test group and user + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user, err := entClient.User.Create(). + SetName("testuser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create test database and questions + database := createTestDatabase(t, entClient) + easyQuestion1 := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + easyQuestion2 := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + mediumQuestion1 := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyMedium) + mediumQuestion2 := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyMedium) + mediumQuestion3 := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyMedium) + hardQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyHard) + + // Create successful submissions + createTestSubmission(t, entClient, user, easyQuestion1, "SELECT * FROM test;", submission.StatusSuccess, time.Now().Add(-5*time.Hour)) + createTestSubmission(t, entClient, user, easyQuestion2, "SELECT * FROM test;", submission.StatusSuccess, time.Now().Add(-4*time.Hour)) + createTestSubmission(t, entClient, user, mediumQuestion1, "SELECT * FROM test;", submission.StatusSuccess, time.Now().Add(-3*time.Hour)) + createTestSubmission(t, entClient, user, mediumQuestion2, "SELECT * FROM test;", submission.StatusSuccess, time.Now().Add(-2*time.Hour)) + createTestSubmission(t, entClient, user, mediumQuestion3, "SELECT * FROM test;", submission.StatusSuccess, time.Now().Add(-1*time.Hour)) + createTestSubmission(t, entClient, user, hardQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now()) + + // Execute query + var resp struct { + User struct { + SubmissionStatistics struct { + TotalQuestions int `json:"totalQuestions"` + AttemptedQuestions int `json:"attemptedQuestions"` + SolvedQuestions int `json:"solvedQuestions"` + SolvedQuestionByDifficulty []struct { + Difficulty string `json:"difficulty"` + SolvedQuestions int `json:"solvedQuestions"` + } `json:"solvedQuestionByDifficulty"` + } `json:"submissionStatistics"` + } `json:"user"` + } + + query := `query { + user(id: ` + strconv.Itoa(user.ID) + `) { + submissionStatistics { + totalQuestions + attemptedQuestions + solvedQuestions + solvedQuestionByDifficulty { + difficulty + solvedQuestions + } + } + } + }` + + 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"}, + })) + }) + + require.NoError(t, err) + require.Equal(t, 6, resp.User.SubmissionStatistics.TotalQuestions) + require.Equal(t, 6, resp.User.SubmissionStatistics.AttemptedQuestions) + require.Equal(t, 6, resp.User.SubmissionStatistics.SolvedQuestions) + require.Len(t, resp.User.SubmissionStatistics.SolvedQuestionByDifficulty, 3) + + // Verify difficulty breakdown + difficultyMap := make(map[string]int) + for _, item := range resp.User.SubmissionStatistics.SolvedQuestionByDifficulty { + difficultyMap[item.Difficulty] = item.SolvedQuestions + } + require.Equal(t, 2, difficultyMap["easy"]) + require.Equal(t, 3, difficultyMap["medium"]) + require.Equal(t, 1, difficultyMap["hard"]) + }) + + t.Run("user with mixed successful and failed submissions", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Create test group and user + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user, err := entClient.User.Create(). + SetName("testuser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create test database and questions + database := createTestDatabase(t, entClient) + easyQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + mediumQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyMedium) + hardQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyHard) + unsolvedQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + + // Create mixed submissions + // Easy question: solved after initial failure + createTestSubmission(t, entClient, user, easyQuestion, "SELECT * FROM wrong;", submission.StatusFailed, time.Now().Add(-2*time.Hour)) + createTestSubmission(t, entClient, user, easyQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now().Add(-1*time.Hour)) + + // Medium question: solved + createTestSubmission(t, entClient, user, mediumQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now()) + + // Hard question: failed attempts only + createTestSubmission(t, entClient, user, hardQuestion, "SELECT * FROM invalid;", submission.StatusFailed, time.Now().Add(-30*time.Minute)) + + // Unsolved question: failed attempts only + createTestSubmission(t, entClient, user, unsolvedQuestion, "SELECT * FROM bad;", submission.StatusFailed, time.Now().Add(-15*time.Minute)) + + // Execute query + var resp struct { + User struct { + SubmissionStatistics struct { + TotalQuestions int `json:"totalQuestions"` + AttemptedQuestions int `json:"attemptedQuestions"` + SolvedQuestions int `json:"solvedQuestions"` + SolvedQuestionByDifficulty []struct { + Difficulty string `json:"difficulty"` + SolvedQuestions int `json:"solvedQuestions"` + } `json:"solvedQuestionByDifficulty"` + } `json:"submissionStatistics"` + } `json:"user"` + } + + query := `query { + user(id: ` + strconv.Itoa(user.ID) + `) { + submissionStatistics { + totalQuestions + attemptedQuestions + solvedQuestions + solvedQuestionByDifficulty { + difficulty + solvedQuestions + } + } + } + }` + + 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"}, + })) + }) + + require.NoError(t, err) + require.Equal(t, 4, resp.User.SubmissionStatistics.TotalQuestions) + require.Equal(t, 4, resp.User.SubmissionStatistics.AttemptedQuestions) // All 4 questions attempted + require.Equal(t, 2, resp.User.SubmissionStatistics.SolvedQuestions) // Only 2 questions solved + + // Verify difficulty breakdown (only solved questions should appear) + require.Len(t, resp.User.SubmissionStatistics.SolvedQuestionByDifficulty, 2) + difficultyMap := make(map[string]int) + for _, item := range resp.User.SubmissionStatistics.SolvedQuestionByDifficulty { + difficultyMap[item.Difficulty] = item.SolvedQuestions + } + require.Equal(t, 1, difficultyMap["easy"]) // 1 easy question solved + require.Equal(t, 1, difficultyMap["medium"]) // 1 medium question solved + // Hard should not appear in the breakdown since no hard questions were solved + _, hardExists := difficultyMap["hard"] + require.False(t, hardExists) + }) + + t.Run("user isolation - statistics don't leak between users", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Create test group and users + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user1, err := entClient.User.Create(). + SetName("user1"). + SetEmail("user1@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + user2, err := entClient.User.Create(). + SetName("user2"). + SetEmail("user2@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create test database and questions + database := createTestDatabase(t, entClient) + easyQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + mediumQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyMedium) + + // Create submissions for user1 only + createTestSubmission(t, entClient, user1, easyQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now().Add(-1*time.Hour)) + createTestSubmission(t, entClient, user1, mediumQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now()) + + // Execute query for user1 + var resp1 struct { + User struct { + SubmissionStatistics struct { + TotalQuestions int `json:"totalQuestions"` + AttemptedQuestions int `json:"attemptedQuestions"` + SolvedQuestions int `json:"solvedQuestions"` + SolvedQuestionByDifficulty []struct { + Difficulty string `json:"difficulty"` + SolvedQuestions int `json:"solvedQuestions"` + } `json:"solvedQuestionByDifficulty"` + } `json:"submissionStatistics"` + } `json:"user"` + } + + query1 := `query { + user(id: ` + strconv.Itoa(user1.ID) + `) { + submissionStatistics { + totalQuestions + attemptedQuestions + solvedQuestions + solvedQuestionByDifficulty { + difficulty + solvedQuestions + } + } + } + }` + + 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) + require.Equal(t, 2, resp1.User.SubmissionStatistics.TotalQuestions) + require.Equal(t, 2, resp1.User.SubmissionStatistics.AttemptedQuestions) + require.Equal(t, 2, resp1.User.SubmissionStatistics.SolvedQuestions) + + // Execute query for user2 (should have no submissions) + var resp2 struct { + User struct { + SubmissionStatistics struct { + TotalQuestions int `json:"totalQuestions"` + AttemptedQuestions int `json:"attemptedQuestions"` + SolvedQuestions int `json:"solvedQuestions"` + SolvedQuestionByDifficulty []struct { + Difficulty string `json:"difficulty"` + SolvedQuestions int `json:"solvedQuestions"` + } `json:"solvedQuestionByDifficulty"` + } `json:"submissionStatistics"` + } `json:"user"` + } + + query2 := `query { + user(id: ` + strconv.Itoa(user2.ID) + `) { + submissionStatistics { + totalQuestions + attemptedQuestions + solvedQuestions + solvedQuestionByDifficulty { + difficulty + solvedQuestions + } + } + } + }` + + err = gqlClient.Post(query2, &resp2, 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) + require.Equal(t, 2, resp2.User.SubmissionStatistics.TotalQuestions) // Same total questions in system + require.Equal(t, 0, resp2.User.SubmissionStatistics.AttemptedQuestions) // But user2 has no attempts + require.Equal(t, 0, resp2.User.SubmissionStatistics.SolvedQuestions) // And no solved questions + require.Empty(t, resp2.User.SubmissionStatistics.SolvedQuestionByDifficulty) + }) + + t.Run("direct resolver method call", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + // Create test group and user + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user, err := entClient.User.Create(). + SetName("testuser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create test database and questions + database := createTestDatabase(t, entClient) + easyQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + mediumQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyMedium) + + // Create submissions + createTestSubmission(t, entClient, user, easyQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now().Add(-1*time.Hour)) + createTestSubmission(t, entClient, user, mediumQuestion, "SELECT * FROM wrong;", submission.StatusFailed, time.Now()) + + // Test the resolver method directly + userResolver := &userResolver{resolver} + stats, err := userResolver.SubmissionStatistics(context.Background(), user) + require.NoError(t, err) + + require.Equal(t, 2, stats.TotalQuestions) + require.Equal(t, 2, stats.AttemptedQuestions) + require.Equal(t, 1, stats.SolvedQuestions) + require.Len(t, stats.SolvedQuestionByDifficulty, 1) + require.Equal(t, question.DifficultyEasy, stats.SolvedQuestionByDifficulty[0].Difficulty) + require.Equal(t, 1, stats.SolvedQuestionByDifficulty[0].SolvedQuestions) + }) + + t.Run("error handling - missing attempted questions count", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + + // Create test group and user + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user, err := entClient.User.Create(). + SetName("testuser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Test that the resolver handles missing attempted questions count gracefully + // This tests the error path in the resolver where the attempted questions query fails + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + userResolver := &userResolver{resolver} + + // Close the client to simulate a database error + entClient.Close() + + _, err = userResolver.SubmissionStatistics(context.Background(), user) + require.Error(t, err) + require.Contains(t, err.Error(), "retrieving total questions") + }) +} + +// Helper function to create a question with specific difficulty +func createTestQuestionWithDifficulty(t *testing.T, entClient *ent.Client, database *ent.Database, difficulty question.Difficulty) *ent.Question { + t.Helper() + question, err := entClient.Question.Create(). + SetCategory("test-query-" + string(difficulty) + "-" + strconv.FormatInt(time.Now().UnixNano(), 10)). + SetDifficulty(difficulty). + SetTitle("Test Query " + string(difficulty)). + SetDescription("Write a SELECT query for " + string(difficulty) + " difficulty"). + SetReferenceAnswer("SELECT * FROM test;"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + return question +} From fca915ec6135f77905e9aad7b8c1d7f9986b8b94 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sat, 20 Sep 2025 00:17:37 +0800 Subject: [PATCH 2/3] style: reformat codebase --- graph/ent.resolvers.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/graph/ent.resolvers.go b/graph/ent.resolvers.go index 91d6b6b..2928817 100644 --- a/graph/ent.resolvers.go +++ b/graph/ent.resolvers.go @@ -91,6 +91,8 @@ func (r *Resolver) Question() QuestionResolver { return &questionResolver{r} } // User returns UserResolver implementation. func (r *Resolver) User() UserResolver { return &userResolver{r} } -type queryResolver struct{ *Resolver } -type questionResolver struct{ *Resolver } -type userResolver struct{ *Resolver } +type ( + queryResolver struct{ *Resolver } + questionResolver struct{ *Resolver } + userResolver struct{ *Resolver } +) From e763cf7aa0e09f2fd5d2519aa52f710dbaf8b69e Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sat, 20 Sep 2025 00:22:50 +0800 Subject: [PATCH 3/3] fix(graph): apply copilot suggestion --- graph/question.resolvers.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graph/question.resolvers.go b/graph/question.resolvers.go index b53d3d5..97e42c7 100644 --- a/graph/question.resolvers.go +++ b/graph/question.resolvers.go @@ -257,7 +257,7 @@ func (r *userResolver) SubmissionStatistics(ctx context.Context, obj *ent.User) Count int `json:"count,omitempty"` } - // total questios + // total questions totalQuestions, err := entClient.Question.Query().Count(ctx) if err != nil { return nil, fmt.Errorf("retrieving total questions: %w", err) @@ -267,6 +267,9 @@ func (r *userResolver) SubmissionStatistics(ctx context.Context, obj *ent.User) attemptedQuestions, err := entClient.Question.Query().Where( entQuestion.HasSubmissionsWith(entSubmission.HasUserWith(user.ID(obj.ID))), ).Count(ctx) + if err != nil { + return nil, fmt.Errorf("retrieving attempted questions: %w", err) + } // solved solvedQuestions, err := entClient.Question.Query().Where(