go-api/graphql_test.go
Snider d517fa2d71 feat: add WithGraphQL endpoint and playground support
Mount a gqlgen ExecutableSchema as a Gin handler at /graphql with
optional playground UI at /graphql/playground. Supports custom path
via WithGraphQLPath().

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 00:12:25 +00:00

234 lines
6.1 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/99designs/gqlgen/graphql"
"github.com/gin-gonic/gin"
"github.com/vektah/gqlparser/v2"
"github.com/vektah/gqlparser/v2/ast"
api "forge.lthn.ai/core/go-api"
)
// newTestSchema creates a minimal ExecutableSchema that responds to { name }
// with {"name":"test"}. This avoids importing gqlgen's internal testserver
// while providing a realistic schema for handler tests.
func newTestSchema() graphql.ExecutableSchema {
schema := gqlparser.MustLoadSchema(&ast.Source{Input: `
type Query {
name: String!
}
`})
return &graphql.ExecutableSchemaMock{
SchemaFunc: func() *ast.Schema {
return schema
},
ExecFunc: func(ctx context.Context) graphql.ResponseHandler {
ran := false
return func(ctx context.Context) *graphql.Response {
if ran {
return nil
}
ran = true
return &graphql.Response{Data: []byte(`{"name":"test"}`)}
}
},
ComplexityFunc: func(_ context.Context, _, _ string, childComplexity int, _ map[string]any) (int, bool) {
return childComplexity, true
},
}
}
// ── GraphQL endpoint ──────────────────────────────────────────────────
func TestWithGraphQL_Good_EndpointResponds(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithGraphQL(newTestSchema()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
body := `{"query":"{ name }"}`
resp, err := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(respBody), `"name":"test"`) {
t.Fatalf("expected response containing name:test, got %q", string(respBody))
}
}
func TestWithGraphQL_Good_PlaygroundServesHTML(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithPlayground()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/graphql/playground")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Fatalf("expected Content-Type containing text/html, got %q", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(body), "GraphQL") {
t.Fatalf("expected playground HTML containing 'GraphQL', got %q", string(body)[:200])
}
}
func TestWithGraphQL_Good_NoPlaygroundByDefault(t *testing.T) {
gin.SetMode(gin.TestMode)
// Without WithPlayground(), /graphql/playground should return 404.
e, err := api.New(api.WithGraphQL(newTestSchema()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/graphql/playground", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for /graphql/playground without WithPlayground, got %d", w.Code)
}
}
func TestWithGraphQL_Good_CustomPath(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath("/gql"), api.WithPlayground()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
// Query endpoint should be at /gql.
body := `{"query":"{ name }"}`
resp, err := http.Post(srv.URL+"/gql", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 at /gql, got %d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(respBody), `"name":"test"`) {
t.Fatalf("expected response containing name:test, got %q", string(respBody))
}
// Playground should be at /gql/playground.
pgResp, err := http.Get(srv.URL + "/gql/playground")
if err != nil {
t.Fatalf("playground request failed: %v", err)
}
defer pgResp.Body.Close()
if pgResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 at /gql/playground, got %d", pgResp.StatusCode)
}
// The default path should not exist.
defaultResp, err := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("default path request failed: %v", err)
}
defer defaultResp.Body.Close()
if defaultResp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 at /graphql when custom path is /gql, got %d", defaultResp.StatusCode)
}
}
func TestWithGraphQL_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(
api.WithRequestID(),
api.WithGraphQL(newTestSchema()),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
body := `{"query":"{ name }"}`
resp, err := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
// RequestID middleware should have injected the header.
reqID := resp.Header.Get("X-Request-ID")
if reqID == "" {
t.Fatal("expected X-Request-ID header from RequestID middleware")
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(respBody), `"name":"test"`) {
t.Fatalf("expected response containing name:test, got %q", string(respBody))
}
}