CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
graphql.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| eae38d1 | 1 | /** |
| 2 | * Block G2 — GraphQL parser + endpoint smoke tests. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import app from "../app"; | |
| 7 | import { parseQuery, execute } from "../lib/graphql"; | |
| 8 | ||
| 9 | describe("graphql — parseQuery", () => { | |
| 10 | it("parses a bare selection set", () => { | |
| 11 | const r = parseQuery("{ viewer { id username } }"); | |
| 12 | expect(r.ok).toBe(true); | |
| 13 | if (r.ok) { | |
| 14 | expect(r.fields.length).toBe(1); | |
| 15 | expect(r.fields[0].name).toBe("viewer"); | |
| 16 | expect(r.fields[0].selections.map((s) => s.name)).toEqual([ | |
| 17 | "id", | |
| 18 | "username", | |
| 19 | ]); | |
| 20 | } | |
| 21 | }); | |
| 22 | ||
| 23 | it("parses a query with operation keyword", () => { | |
| 24 | const r = parseQuery("query Foo { rateLimit { remaining } }"); | |
| 25 | expect(r.ok).toBe(true); | |
| 26 | if (r.ok) { | |
| 27 | expect(r.fields[0].name).toBe("rateLimit"); | |
| 28 | } | |
| 29 | }); | |
| 30 | ||
| 31 | it("parses string args", () => { | |
| 32 | const r = parseQuery('{ user(username:"alice") { id } }'); | |
| 33 | expect(r.ok).toBe(true); | |
| 34 | if (r.ok) { | |
| 35 | expect(r.fields[0].args.username).toBe("alice"); | |
| 36 | } | |
| 37 | }); | |
| 38 | ||
| 39 | it("parses number + boolean args", () => { | |
| 40 | const r = parseQuery('{ search(q:"x", limit:5) { id } }'); | |
| 41 | expect(r.ok).toBe(true); | |
| 42 | if (r.ok) { | |
| 43 | expect(r.fields[0].args.limit).toBe(5); | |
| 44 | } | |
| 45 | }); | |
| 46 | ||
| 47 | it("parses aliases", () => { | |
| 48 | const r = parseQuery("{ me:viewer { id } }"); | |
| 49 | expect(r.ok).toBe(true); | |
| 50 | if (r.ok) { | |
| 51 | expect(r.fields[0].name).toBe("viewer"); | |
| 52 | expect(r.fields[0].alias).toBe("me"); | |
| 53 | } | |
| 54 | }); | |
| 55 | ||
| 56 | it("parses nested selections", () => { | |
| 57 | const r = parseQuery( | |
| 58 | `{ repository(owner:"alice", name:"repo") { owner { username } issues(state:"open", limit:5) { title } } }` | |
| 59 | ); | |
| 60 | expect(r.ok).toBe(true); | |
| 61 | if (r.ok) { | |
| 62 | const repo = r.fields[0]; | |
| 63 | expect(repo.name).toBe("repository"); | |
| 64 | const owner = repo.selections.find((s) => s.name === "owner"); | |
| 65 | expect(owner).toBeDefined(); | |
| 66 | expect(owner!.selections.map((s) => s.name)).toEqual(["username"]); | |
| 67 | } | |
| 68 | }); | |
| 69 | ||
| 70 | it("skips comments + commas", () => { | |
| 71 | const r = parseQuery("{ # comment\n viewer, { id, username } }"); | |
| 72 | expect(r.ok).toBe(true); | |
| 73 | }); | |
| 74 | ||
| 75 | it("returns an error on malformed input", () => { | |
| 76 | const r = parseQuery("{ viewer {"); | |
| 77 | expect(r.ok).toBe(false); | |
| 78 | }); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("graphql — execute", () => { | |
| 82 | it("returns data for rateLimit (no-side-effect field)", async () => { | |
| 83 | const r = await execute("{ rateLimit { remaining reset } }", { user: null }); | |
| 84 | expect(r.data).toBeDefined(); | |
| 85 | expect(r.data?.rateLimit).toBeDefined(); | |
| 86 | expect(typeof r.data?.rateLimit.remaining).toBe("number"); | |
| 87 | expect(typeof r.data?.rateLimit.reset).toBe("number"); | |
| 88 | }); | |
| 89 | ||
| 90 | it("viewer returns null without auth", async () => { | |
| 91 | const r = await execute("{ viewer { id } }", { user: null }); | |
| 92 | expect(r.data?.viewer).toBe(null); | |
| 93 | }); | |
| 94 | ||
| 95 | it("unknown root field → error + null data", async () => { | |
| 96 | const r = await execute("{ bogus { id } }", { user: null }); | |
| 97 | expect(r.errors).toBeDefined(); | |
| 98 | expect(r.errors![0].message).toContain("bogus"); | |
| 99 | expect(r.data?.bogus).toBe(null); | |
| 100 | }); | |
| 101 | ||
| 102 | it("parse error surfaces in errors", async () => { | |
| 103 | const r = await execute("{ viewer {", { user: null }); | |
| 104 | expect(r.errors).toBeDefined(); | |
| 105 | }); | |
| 106 | ||
| 107 | it("user(username) on nonexistent returns null", async () => { | |
| 108 | const r = await execute( | |
| 109 | '{ user(username:"__zzzz_doesnt_exist") { id } }', | |
| 110 | { user: null } | |
| 111 | ); | |
| 112 | expect(r.data?.user).toBe(null); | |
| 113 | }); | |
| 114 | ||
| 115 | it("repository on nonexistent returns null", async () => { | |
| 116 | const r = await execute( | |
| 117 | '{ repository(owner:"__nope", name:"__nope") { id } }', | |
| 118 | { user: null } | |
| 119 | ); | |
| 120 | expect(r.data?.repository).toBe(null); | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 124 | describe("graphql — HTTP endpoint", () => { | |
| 125 | it("POST /api/graphql with empty query → 400", async () => { | |
| 126 | const res = await app.request("/api/graphql", { | |
| 127 | method: "POST", | |
| 128 | headers: { "content-type": "application/json" }, | |
| 129 | body: JSON.stringify({ query: "" }), | |
| 130 | }); | |
| 131 | expect(res.status).toBe(400); | |
| 132 | }); | |
| 133 | ||
| 134 | it("POST /api/graphql with invalid JSON → 400", async () => { | |
| 135 | const res = await app.request("/api/graphql", { | |
| 136 | method: "POST", | |
| 137 | headers: { "content-type": "application/json" }, | |
| 138 | body: "{not json", | |
| 139 | }); | |
| 140 | expect(res.status).toBe(400); | |
| 141 | }); | |
| 142 | ||
| 143 | it("POST /api/graphql rateLimit query returns JSON", async () => { | |
| 144 | const res = await app.request("/api/graphql", { | |
| 145 | method: "POST", | |
| 146 | headers: { "content-type": "application/json" }, | |
| 147 | body: JSON.stringify({ query: "{ rateLimit { remaining } }" }), | |
| 148 | }); | |
| 149 | expect(res.status).toBe(200); | |
| 150 | const body = (await res.json()) as any; | |
| 151 | expect(body.data.rateLimit.remaining).toBeGreaterThan(0); | |
| 152 | }); | |
| 153 | ||
| 154 | it("GET /api/graphql serves a GraphiQL-lite explorer page", async () => { | |
| 155 | const res = await app.request("/api/graphql"); | |
| 156 | expect(res.status).toBe(200); | |
| 157 | expect(res.headers.get("content-type") || "").toContain("text/html"); | |
| 158 | const html = await res.text(); | |
| 159 | expect(html).toContain("gluecron"); | |
| 160 | expect(html).toContain("/api/graphql"); | |
| 161 | }); | |
| 162 | }); |