diff --git a/flake.lock b/flake.lock index 26a4cfc..b403735 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1760164275, - "narHash": "sha256-gKl2Gtro/LNf8P+4L3S2RsZ0G390ccd5MyXYrTdMCFE=", + "lastModified": 1760965567, + "narHash": "sha256-0JDOal5P7xzzAibvD0yTE3ptyvoVOAL0rcELmDdtSKg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "362791944032cb532aabbeed7887a441496d5e6e", + "rev": "cb82756ecc37fa623f8cf3e88854f9bf7f64af93", "type": "github" }, "original": { diff --git a/go.mod b/go.mod index ab6c407..f524cfd 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/samber/lo v1.52.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.39.0 - github.com/urfave/cli/v3 v3.4.1 + github.com/urfave/cli/v3 v3.5.0 github.com/vektah/gqlparser/v2 v2.5.30 go.uber.org/fx v1.24.0 golang.org/x/oauth2 v0.32.0 @@ -81,7 +81,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect @@ -138,14 +138,14 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.43.0 // indirect - golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b // indirect + golang.org/x/exp v0.0.0-20251017212417-90e834f514db // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect golang.org/x/tools v0.38.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251020155222-88f65dc88635 // indirect google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 0b91d28..b84646f 100644 --- a/go.sum +++ b/go.sum @@ -158,8 +158,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -269,8 +269,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= -github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= -github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/urfave/cli/v3 v3.5.0 h1:qCuFMmdayTF3zmjG8TSsoBzrDqszNrklYg2x3g4MSgw= +github.com/urfave/cli/v3 v3.5.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= @@ -323,8 +323,8 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA= -golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/exp v0.0.0-20251017212417-90e834f514db h1:by6IehL4BH5k3e3SJmcoNbOobMey2SLpAF79iPOEBvw= +golang.org/x/exp v0.0.0-20251017212417-90e834f514db/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= @@ -361,8 +361,8 @@ google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuO google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251020155222-88f65dc88635 h1:3uycTxukehWrxH4HtPRtn1PDABTU331ViDjyqrUbaog= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251020155222-88f65dc88635/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= diff --git a/graph/question.graphqls b/graph/question.graphqls index 5e7f48f..e7adac3 100644 --- a/graph/question.graphqls +++ b/graph/question.graphqls @@ -16,6 +16,11 @@ extend type Query { otherwise, you can only get your own submissions. """ submission(id: ID!): Submission! + + """ + Get the list of question categories. + """ + questionCategories: [String!]! @scope(scope: "question:read") } extend type Mutation { diff --git a/graph/question.resolvers.go b/graph/question.resolvers.go index faba1d5..553e838 100644 --- a/graph/question.resolvers.go +++ b/graph/question.resolvers.go @@ -179,6 +179,21 @@ func (r *queryResolver) Submission(ctx context.Context, id int) (*ent.Submission return submission, nil } +// QuestionCategories is the resolver for the questionCategories field. +func (r *queryResolver) QuestionCategories(ctx context.Context) ([]string, error) { + entClient := r.EntClient(ctx) + + categories, err := entClient.Question.Query(). + Unique(true). + Select(entQuestion.FieldCategory). + Strings(ctx) + if err != nil { + return nil, err + } + + return categories, nil +} + // ReferenceAnswerResult is the resolver for the referenceAnswerResult field. func (r *questionResolver) ReferenceAnswerResult(ctx context.Context, obj *ent.Question) (*models.SQLExecutionResult, error) { database, err := obj.QueryDatabase().Only(ctx) diff --git a/graph/question_resolver_test.go b/graph/question_resolver_test.go index 79f4619..18a8c0b 100644 --- a/graph/question_resolver_test.go +++ b/graph/question_resolver_test.go @@ -23,11 +23,13 @@ import ( // createTestDatabase creates a test database entity func createTestDatabase(t *testing.T, entClient *ent.Client) *ent.Database { t.Helper() + // Generate unique slug and relation figure to avoid UNIQUE constraint violations + uniqueID := strconv.FormatInt(time.Now().UnixNano(), 10) database, err := entClient.Database.Create(). - SetSlug("test-db"). + SetSlug("test-db-" + uniqueID). SetDescription("Test Database"). SetSchema("CREATE TABLE test (id INT, name VARCHAR(255));"). - SetRelationFigure("test-relation-figure"). + SetRelationFigure("test-relation-figure-" + uniqueID). Save(context.Background()) require.NoError(t, err) return database @@ -688,3 +690,213 @@ func TestQuestionResolver_Solved(t *testing.T) { require.Contains(t, err.Error(), defs.CodeUnauthorized) }) } + +func TestQueryResolver_QuestionCategories(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) + + testUser, err := entClient.User.Create(). + SetName("testUser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + t.Run("success - returns empty array when no questions", func(t *testing.T) { + var resp struct { + QuestionCategories []string + } + query := `query { questionCategories }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: testUser.ID, + Scopes: []string{"question:read"}, + })) + }) + require.NoError(t, err) + require.NotNil(t, resp.QuestionCategories) + require.Len(t, resp.QuestionCategories, 0) + }) + + t.Run("success - returns single category", func(t *testing.T) { + // Create test database + database := createTestDatabase(t, entClient) + + // Create question with category + _, err := entClient.Question.Create(). + SetCategory("basic-select"). + SetDifficulty("easy"). + SetTitle("Test Query 1"). + SetDescription("Write a SELECT query"). + SetReferenceAnswer("SELECT * FROM test;"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + var resp struct { + QuestionCategories []string + } + query := `query { questionCategories }` + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: testUser.ID, + Scopes: []string{"question:read"}, + })) + }) + require.NoError(t, err) + require.Len(t, resp.QuestionCategories, 1) + require.Contains(t, resp.QuestionCategories, "basic-select") + }) + + t.Run("success - returns unique categories when questions have duplicate categories", func(t *testing.T) { + // Create test database + database := createTestDatabase(t, entClient) + + // Create multiple questions with same category + _, err := entClient.Question.Create(). + SetCategory("joins"). + SetDifficulty("medium"). + SetTitle("Join Query 1"). + SetDescription("Write a JOIN query"). + SetReferenceAnswer("SELECT * FROM test t1 JOIN test t2 ON t1.id = t2.id;"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + _, err = entClient.Question.Create(). + SetCategory("joins"). + SetDifficulty("medium"). + SetTitle("Join Query 2"). + SetDescription("Write another JOIN query"). + SetReferenceAnswer("SELECT * FROM test t1 LEFT JOIN test t2 ON t1.id = t2.id;"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + var resp struct { + QuestionCategories []string + } + query := `query { questionCategories }` + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: testUser.ID, + Scopes: []string{"question:read"}, + })) + }) + require.NoError(t, err) + // Should have at least one category (joins) + require.GreaterOrEqual(t, len(resp.QuestionCategories), 1) + require.Contains(t, resp.QuestionCategories, "joins") + + // Count occurrences of "joins" - should only appear once + joinCount := 0 + for _, cat := range resp.QuestionCategories { + if cat == "joins" { + joinCount++ + } + } + require.Equal(t, 1, joinCount, "Category 'joins' should appear exactly once") + }) + + t.Run("success - returns multiple different categories", func(t *testing.T) { + // Create test database + database := createTestDatabase(t, entClient) + + // Create questions with different categories + _, err := entClient.Question.Create(). + SetCategory("aggregation"). + SetDifficulty("easy"). + SetTitle("Aggregation Query"). + SetDescription("Use aggregation functions"). + SetReferenceAnswer("SELECT COUNT(*) FROM test;"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + _, err = entClient.Question.Create(). + SetCategory("subqueries"). + SetDifficulty("hard"). + SetTitle("Subquery Challenge"). + SetDescription("Use subqueries"). + SetReferenceAnswer("SELECT * FROM test WHERE id IN (SELECT id FROM test);"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + var resp struct { + QuestionCategories []string + } + query := `query { questionCategories }` + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: testUser.ID, + Scopes: []string{"question:read"}, + })) + }) + require.NoError(t, err) + require.GreaterOrEqual(t, len(resp.QuestionCategories), 2) + require.Contains(t, resp.QuestionCategories, "aggregation") + require.Contains(t, resp.QuestionCategories, "subqueries") + }) + + t.Run("success - works with wildcard scope", func(t *testing.T) { + var resp struct { + QuestionCategories []string + } + query := `query { questionCategories }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: testUser.ID, + Scopes: []string{"*"}, + })) + }) + require.NoError(t, err) + require.NotNil(t, resp.QuestionCategories) + // At this point we should have at least categories from previous tests in this run + // Since we don't clean up between tests in the same test function, we expect at least 1 + require.GreaterOrEqual(t, len(resp.QuestionCategories), 1) + }) + + t.Run("forbidden - user without question:read scope", func(t *testing.T) { + var resp struct { + QuestionCategories []string + } + query := `query { questionCategories }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: testUser.ID, + Scopes: []string{"submission:read"}, // Wrong scope + })) + }) + require.Error(t, err) + require.Contains(t, err.Error(), defs.CodeForbidden) + }) + + t.Run("unauthorized - no authentication", func(t *testing.T) { + var resp struct { + QuestionCategories []string + } + query := `query { questionCategories }` + + err := gqlClient.Post(query, &resp) + require.Error(t, err) + require.Contains(t, err.Error(), defs.CodeUnauthorized) + }) +}